mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-18 19:01:23 +02:00
style: auto-format codebase with Prettier
Applied formatting to 1487+ files using pnpm format:write - TypeScript/JavaScript files - Svelte components - Astro pages - JSON configs - Markdown docs 13 files still need manual review (Astro JSX comments)
This commit is contained in:
parent
0241f5554c
commit
d36b321d9d
3952 changed files with 661498 additions and 739751 deletions
|
|
@ -1,8 +1,8 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,46 +1,46 @@
|
|||
{
|
||||
"name": "@nutriphi/backend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"start": "nest start",
|
||||
"dev": "nest start --watch",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.700.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.700.0",
|
||||
"@google/generative-ai": "^0.24.1",
|
||||
"@manacore/nutriphi-database": "workspace:*",
|
||||
"@nestjs/common": "^10.4.15",
|
||||
"@nestjs/config": "^3.3.0",
|
||||
"@nestjs/core": "^10.4.15",
|
||||
"@nestjs/platform-express": "^10.4.15",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.4.9",
|
||||
"@nestjs/schematics": "^10.2.3",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/node": "^22.10.2",
|
||||
"@typescript-eslint/eslint-plugin": "^8.18.1",
|
||||
"@typescript-eslint/parser": "^8.18.1",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-prettier": "^5.2.1",
|
||||
"prettier": "^3.4.2",
|
||||
"source-map-support": "^0.5.21",
|
||||
"ts-loader": "^9.5.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
"name": "@nutriphi/backend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"start": "nest start",
|
||||
"dev": "nest start --watch",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.700.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.700.0",
|
||||
"@google/generative-ai": "^0.24.1",
|
||||
"@manacore/nutriphi-database": "workspace:*",
|
||||
"@nestjs/common": "^10.4.15",
|
||||
"@nestjs/config": "^3.3.0",
|
||||
"@nestjs/core": "^10.4.15",
|
||||
"@nestjs/platform-express": "^10.4.15",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.4.9",
|
||||
"@nestjs/schematics": "^10.2.3",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/node": "^22.10.2",
|
||||
"@typescript-eslint/eslint-plugin": "^8.18.1",
|
||||
"@typescript-eslint/parser": "^8.18.1",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-prettier": "^5.2.1",
|
||||
"prettier": "^3.4.2",
|
||||
"source-map-support": "^0.5.21",
|
||||
"ts-loader": "^9.5.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,17 +8,17 @@ import { MealsModule } from './meals/meals.module';
|
|||
import { SyncModule } from './sync/sync.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
envFilePath: '.env',
|
||||
}),
|
||||
DatabaseModule,
|
||||
StorageModule,
|
||||
HealthModule,
|
||||
GeminiModule,
|
||||
MealsModule,
|
||||
SyncModule,
|
||||
],
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
envFilePath: '.env',
|
||||
}),
|
||||
DatabaseModule,
|
||||
StorageModule,
|
||||
HealthModule,
|
||||
GeminiModule,
|
||||
MealsModule,
|
||||
SyncModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
|
||||
export interface CurrentUserData {
|
||||
userId: string;
|
||||
email: string;
|
||||
role: string;
|
||||
sessionId?: string;
|
||||
userId: string;
|
||||
email: string;
|
||||
role: string;
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(data: unknown, ctx: ExecutionContext): CurrentUserData => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
return request.user;
|
||||
},
|
||||
(data: unknown, ctx: ExecutionContext): CurrentUserData => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
return request.user;
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,66 +1,60 @@
|
|||
import {
|
||||
Injectable,
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard implements CanActivate {
|
||||
constructor(private configService: ConfigService) {}
|
||||
constructor(private configService: ConfigService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const token = this.extractTokenFromHeader(request);
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const token = this.extractTokenFromHeader(request);
|
||||
|
||||
if (!token) {
|
||||
throw new UnauthorizedException('No token provided');
|
||||
}
|
||||
if (!token) {
|
||||
throw new UnauthorizedException('No token provided');
|
||||
}
|
||||
|
||||
try {
|
||||
// Get Mana Core Auth URL from config
|
||||
const authUrl =
|
||||
this.configService.get<string>('MANA_CORE_AUTH_URL') ||
|
||||
'http://localhost:3001';
|
||||
try {
|
||||
// Get Mana Core Auth URL from config
|
||||
const authUrl =
|
||||
this.configService.get<string>('MANA_CORE_AUTH_URL') || 'http://localhost:3001';
|
||||
|
||||
// Validate token with Mana Core Auth
|
||||
const response = await fetch(`${authUrl}/api/v1/auth/validate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token }),
|
||||
});
|
||||
// Validate token with Mana Core Auth
|
||||
const response = await fetch(`${authUrl}/api/v1/auth/validate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new UnauthorizedException('Invalid token');
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new UnauthorizedException('Invalid token');
|
||||
}
|
||||
|
||||
const { valid, payload } = await response.json();
|
||||
const { valid, payload } = await response.json();
|
||||
|
||||
if (!valid || !payload) {
|
||||
throw new UnauthorizedException('Invalid token');
|
||||
}
|
||||
if (!valid || !payload) {
|
||||
throw new UnauthorizedException('Invalid token');
|
||||
}
|
||||
|
||||
// Attach user to request
|
||||
request.user = {
|
||||
userId: payload.sub,
|
||||
email: payload.email,
|
||||
role: payload.role,
|
||||
sessionId: payload.sessionId,
|
||||
};
|
||||
// Attach user to request
|
||||
request.user = {
|
||||
userId: payload.sub,
|
||||
email: payload.email,
|
||||
role: payload.role,
|
||||
sessionId: payload.sessionId,
|
||||
};
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof UnauthorizedException) {
|
||||
throw error;
|
||||
}
|
||||
console.error('Error validating token:', error);
|
||||
throw new UnauthorizedException('Token validation failed');
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof UnauthorizedException) {
|
||||
throw error;
|
||||
}
|
||||
console.error('Error validating token:', error);
|
||||
throw new UnauthorizedException('Token validation failed');
|
||||
}
|
||||
}
|
||||
|
||||
private extractTokenFromHeader(request: any): string | undefined {
|
||||
const [type, token] = request.headers.authorization?.split(' ') ?? [];
|
||||
return type === 'Bearer' ? token : undefined;
|
||||
}
|
||||
private extractTokenFromHeader(request: any): string | undefined {
|
||||
const [type, token] = request.headers.authorization?.split(' ') ?? [];
|
||||
return type === 'Bearer' ? token : undefined;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,23 +6,23 @@ export const DATABASE_TOKEN = 'DATABASE';
|
|||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: DATABASE_TOKEN,
|
||||
useFactory: (configService: ConfigService): Database => {
|
||||
const databaseUrl = configService.get<string>('DATABASE_URL');
|
||||
if (!databaseUrl) {
|
||||
throw new Error('DATABASE_URL environment variable is not set');
|
||||
}
|
||||
return createClient(databaseUrl);
|
||||
},
|
||||
inject: [ConfigService],
|
||||
},
|
||||
],
|
||||
exports: [DATABASE_TOKEN],
|
||||
providers: [
|
||||
{
|
||||
provide: DATABASE_TOKEN,
|
||||
useFactory: (configService: ConfigService): Database => {
|
||||
const databaseUrl = configService.get<string>('DATABASE_URL');
|
||||
if (!databaseUrl) {
|
||||
throw new Error('DATABASE_URL environment variable is not set');
|
||||
}
|
||||
return createClient(databaseUrl);
|
||||
},
|
||||
inject: [ConfigService],
|
||||
},
|
||||
],
|
||||
exports: [DATABASE_TOKEN],
|
||||
})
|
||||
export class DatabaseModule implements OnModuleDestroy {
|
||||
async onModuleDestroy() {
|
||||
await closeDb();
|
||||
}
|
||||
async onModuleDestroy() {
|
||||
await closeDb();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { Module } from '@nestjs/common';
|
|||
import { GeminiService } from './gemini.service';
|
||||
|
||||
@Module({
|
||||
providers: [GeminiService],
|
||||
exports: [GeminiService],
|
||||
providers: [GeminiService],
|
||||
exports: [GeminiService],
|
||||
})
|
||||
export class GeminiModule {}
|
||||
|
|
|
|||
|
|
@ -3,39 +3,39 @@ import { ConfigService } from '@nestjs/config';
|
|||
import { GoogleGenerativeAI } from '@google/generative-ai';
|
||||
|
||||
export interface NutritionAnalysis {
|
||||
foodName: string;
|
||||
calories: number;
|
||||
protein: number;
|
||||
carbohydrates: number;
|
||||
fat: number;
|
||||
fiber: number;
|
||||
sugar: number;
|
||||
sodium: number;
|
||||
servingSize: string;
|
||||
confidence: number;
|
||||
ingredients?: string[];
|
||||
healthTips?: string[];
|
||||
foodName: string;
|
||||
calories: number;
|
||||
protein: number;
|
||||
carbohydrates: number;
|
||||
fat: number;
|
||||
fiber: number;
|
||||
sugar: number;
|
||||
sodium: number;
|
||||
servingSize: string;
|
||||
confidence: number;
|
||||
ingredients?: string[];
|
||||
healthTips?: string[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class GeminiService {
|
||||
private readonly logger = new Logger(GeminiService.name);
|
||||
private readonly genAI: GoogleGenerativeAI;
|
||||
private readonly model;
|
||||
private readonly logger = new Logger(GeminiService.name);
|
||||
private readonly genAI: GoogleGenerativeAI;
|
||||
private readonly model;
|
||||
|
||||
constructor(private configService: ConfigService) {
|
||||
const apiKey = this.configService.get<string>('GEMINI_API_KEY');
|
||||
if (!apiKey) {
|
||||
throw new Error('GEMINI_API_KEY is not configured');
|
||||
}
|
||||
this.genAI = new GoogleGenerativeAI(apiKey);
|
||||
this.model = this.genAI.getGenerativeModel({ model: 'gemini-1.5-flash' });
|
||||
}
|
||||
constructor(private configService: ConfigService) {
|
||||
const apiKey = this.configService.get<string>('GEMINI_API_KEY');
|
||||
if (!apiKey) {
|
||||
throw new Error('GEMINI_API_KEY is not configured');
|
||||
}
|
||||
this.genAI = new GoogleGenerativeAI(apiKey);
|
||||
this.model = this.genAI.getGenerativeModel({ model: 'gemini-1.5-flash' });
|
||||
}
|
||||
|
||||
async analyzeFoodImage(imageBase64: string): Promise<NutritionAnalysis> {
|
||||
this.logger.log('Analyzing food image with Gemini Vision');
|
||||
async analyzeFoodImage(imageBase64: string): Promise<NutritionAnalysis> {
|
||||
this.logger.log('Analyzing food image with Gemini Vision');
|
||||
|
||||
const prompt = `Analyze this food image and provide detailed nutritional information.
|
||||
const prompt = `Analyze this food image and provide detailed nutritional information.
|
||||
|
||||
Return a JSON object with the following structure:
|
||||
{
|
||||
|
|
@ -56,38 +56,38 @@ export class GeminiService {
|
|||
Be as accurate as possible with the nutritional estimates based on what you can see.
|
||||
Only return valid JSON, no additional text.`;
|
||||
|
||||
try {
|
||||
const result = await this.model.generateContent([
|
||||
prompt,
|
||||
{
|
||||
inlineData: {
|
||||
mimeType: 'image/jpeg',
|
||||
data: imageBase64,
|
||||
},
|
||||
},
|
||||
]);
|
||||
try {
|
||||
const result = await this.model.generateContent([
|
||||
prompt,
|
||||
{
|
||||
inlineData: {
|
||||
mimeType: 'image/jpeg',
|
||||
data: imageBase64,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const response = result.response.text();
|
||||
const jsonMatch = response.match(/\{[\s\S]*\}/);
|
||||
const response = result.response.text();
|
||||
const jsonMatch = response.match(/\{[\s\S]*\}/);
|
||||
|
||||
if (!jsonMatch) {
|
||||
throw new Error('No valid JSON found in response');
|
||||
}
|
||||
if (!jsonMatch) {
|
||||
throw new Error('No valid JSON found in response');
|
||||
}
|
||||
|
||||
const analysis: NutritionAnalysis = JSON.parse(jsonMatch[0]);
|
||||
this.logger.log(`Successfully analyzed: ${analysis.foodName}`);
|
||||
const analysis: NutritionAnalysis = JSON.parse(jsonMatch[0]);
|
||||
this.logger.log(`Successfully analyzed: ${analysis.foodName}`);
|
||||
|
||||
return analysis;
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to analyze food image', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return analysis;
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to analyze food image', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async analyzeFoodText(description: string): Promise<NutritionAnalysis> {
|
||||
this.logger.log('Analyzing food description with Gemini');
|
||||
async analyzeFoodText(description: string): Promise<NutritionAnalysis> {
|
||||
this.logger.log('Analyzing food description with Gemini');
|
||||
|
||||
const prompt = `Based on this food description, provide detailed nutritional information: "${description}"
|
||||
const prompt = `Based on this food description, provide detailed nutritional information: "${description}"
|
||||
|
||||
Return a JSON object with the following structure:
|
||||
{
|
||||
|
|
@ -107,22 +107,22 @@ export class GeminiService {
|
|||
|
||||
Only return valid JSON, no additional text.`;
|
||||
|
||||
try {
|
||||
const result = await this.model.generateContent(prompt);
|
||||
const response = result.response.text();
|
||||
const jsonMatch = response.match(/\{[\s\S]*\}/);
|
||||
try {
|
||||
const result = await this.model.generateContent(prompt);
|
||||
const response = result.response.text();
|
||||
const jsonMatch = response.match(/\{[\s\S]*\}/);
|
||||
|
||||
if (!jsonMatch) {
|
||||
throw new Error('No valid JSON found in response');
|
||||
}
|
||||
if (!jsonMatch) {
|
||||
throw new Error('No valid JSON found in response');
|
||||
}
|
||||
|
||||
const analysis: NutritionAnalysis = JSON.parse(jsonMatch[0]);
|
||||
this.logger.log(`Successfully analyzed: ${analysis.foodName}`);
|
||||
const analysis: NutritionAnalysis = JSON.parse(jsonMatch[0]);
|
||||
this.logger.log(`Successfully analyzed: ${analysis.foodName}`);
|
||||
|
||||
return analysis;
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to analyze food description', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return analysis;
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to analyze food description', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@ import { Controller, Get } from '@nestjs/common';
|
|||
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
@Get()
|
||||
check() {
|
||||
return {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
service: 'nutriphi-backend',
|
||||
};
|
||||
}
|
||||
@Get()
|
||||
check() {
|
||||
return {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
service: 'nutriphi-backend',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,6 @@ import { Module } from '@nestjs/common';
|
|||
import { HealthController } from './health.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
|
|
|
|||
|
|
@ -3,34 +3,34 @@ import { ValidationPipe } from '@nestjs/common';
|
|||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
// Enable CORS for mobile and web apps
|
||||
app.enableCors({
|
||||
origin: [
|
||||
'http://localhost:3000',
|
||||
'http://localhost:5173',
|
||||
'http://localhost:8081',
|
||||
'exp://localhost:8081',
|
||||
],
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||
credentials: true,
|
||||
});
|
||||
// Enable CORS for mobile and web apps
|
||||
app.enableCors({
|
||||
origin: [
|
||||
'http://localhost:3000',
|
||||
'http://localhost:5173',
|
||||
'http://localhost:8081',
|
||||
'exp://localhost:8081',
|
||||
],
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
// Enable validation
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
transform: true,
|
||||
forbidNonWhitelisted: true,
|
||||
}),
|
||||
);
|
||||
// Enable validation
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
transform: true,
|
||||
forbidNonWhitelisted: true,
|
||||
})
|
||||
);
|
||||
|
||||
// Set global prefix for API routes
|
||||
app.setGlobalPrefix('api');
|
||||
// Set global prefix for API routes
|
||||
app.setGlobalPrefix('api');
|
||||
|
||||
const port = process.env.PORT || 3002;
|
||||
await app.listen(port);
|
||||
console.log(`Nutriphi backend running on http://localhost:${port}`);
|
||||
const port = process.env.PORT || 3002;
|
||||
await app.listen(port);
|
||||
console.log(`Nutriphi backend running on http://localhost:${port}`);
|
||||
}
|
||||
bootstrap();
|
||||
|
|
|
|||
|
|
@ -1,87 +1,87 @@
|
|||
import { IsString, IsOptional, IsBase64 } from 'class-validator';
|
||||
|
||||
export class AnalyzeMealImageDto {
|
||||
@IsString()
|
||||
imageBase64: string;
|
||||
@IsString()
|
||||
imageBase64: string;
|
||||
}
|
||||
|
||||
export class AnalyzeMealTextDto {
|
||||
@IsString()
|
||||
description: string;
|
||||
@IsString()
|
||||
description: string;
|
||||
}
|
||||
|
||||
export class CreateMealDto {
|
||||
@IsString()
|
||||
foodName: string;
|
||||
@IsString()
|
||||
foodName: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
imageUrl?: string;
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
imageUrl?: string;
|
||||
|
||||
calories: number;
|
||||
protein: number;
|
||||
carbohydrates: number;
|
||||
fat: number;
|
||||
fiber: number;
|
||||
sugar: number;
|
||||
sodium: number;
|
||||
calories: number;
|
||||
protein: number;
|
||||
carbohydrates: number;
|
||||
fat: number;
|
||||
fiber: number;
|
||||
sugar: number;
|
||||
sodium: number;
|
||||
|
||||
@IsString()
|
||||
servingSize: string;
|
||||
@IsString()
|
||||
servingSize: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
mealType?: 'breakfast' | 'lunch' | 'dinner' | 'snack';
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
mealType?: 'breakfast' | 'lunch' | 'dinner' | 'snack';
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export class UploadMealDto {
|
||||
@IsString()
|
||||
imageBase64: string;
|
||||
@IsString()
|
||||
imageBase64: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
mealType?: 'breakfast' | 'lunch' | 'dinner' | 'snack';
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
mealType?: 'breakfast' | 'lunch' | 'dinner' | 'snack';
|
||||
}
|
||||
|
||||
export class UpdateMealDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
foodName?: string;
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
foodName?: string;
|
||||
|
||||
@IsOptional()
|
||||
calories?: number;
|
||||
@IsOptional()
|
||||
calories?: number;
|
||||
|
||||
@IsOptional()
|
||||
protein?: number;
|
||||
@IsOptional()
|
||||
protein?: number;
|
||||
|
||||
@IsOptional()
|
||||
carbohydrates?: number;
|
||||
@IsOptional()
|
||||
carbohydrates?: number;
|
||||
|
||||
@IsOptional()
|
||||
fat?: number;
|
||||
@IsOptional()
|
||||
fat?: number;
|
||||
|
||||
@IsOptional()
|
||||
fiber?: number;
|
||||
@IsOptional()
|
||||
fiber?: number;
|
||||
|
||||
@IsOptional()
|
||||
sugar?: number;
|
||||
@IsOptional()
|
||||
sugar?: number;
|
||||
|
||||
@IsOptional()
|
||||
sodium?: number;
|
||||
@IsOptional()
|
||||
sodium?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
servingSize?: string;
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
servingSize?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
mealType?: 'breakfast' | 'lunch' | 'dinner' | 'snack';
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
mealType?: 'breakfast' | 'lunch' | 'dinner' | 'snack';
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,23 +1,23 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
UseGuards,
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { MealsService } from './meals.service';
|
||||
import {
|
||||
AnalyzeMealImageDto,
|
||||
AnalyzeMealTextDto,
|
||||
CreateMealDto,
|
||||
UpdateMealDto,
|
||||
UploadMealDto,
|
||||
AnalyzeMealImageDto,
|
||||
AnalyzeMealTextDto,
|
||||
CreateMealDto,
|
||||
UpdateMealDto,
|
||||
UploadMealDto,
|
||||
} from './dto/analyze-meal.dto';
|
||||
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
|
||||
import { CurrentUser, CurrentUserData } from '../common/decorators/current-user.decorator';
|
||||
|
|
@ -25,75 +25,57 @@ import { CurrentUser, CurrentUserData } from '../common/decorators/current-user.
|
|||
@Controller('meals')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class MealsController {
|
||||
constructor(private readonly mealsService: MealsService) {}
|
||||
constructor(private readonly mealsService: MealsService) {}
|
||||
|
||||
@Post('analyze/image')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async analyzeImage(@Body() dto: AnalyzeMealImageDto) {
|
||||
return this.mealsService.analyzeImage(dto.imageBase64);
|
||||
}
|
||||
@Post('analyze/image')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async analyzeImage(@Body() dto: AnalyzeMealImageDto) {
|
||||
return this.mealsService.analyzeImage(dto.imageBase64);
|
||||
}
|
||||
|
||||
@Post('analyze/text')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async analyzeText(@Body() dto: AnalyzeMealTextDto) {
|
||||
return this.mealsService.analyzeText(dto.description);
|
||||
}
|
||||
@Post('analyze/text')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async analyzeText(@Body() dto: AnalyzeMealTextDto) {
|
||||
return this.mealsService.analyzeText(dto.description);
|
||||
}
|
||||
|
||||
@Post()
|
||||
async createMeal(
|
||||
@Body() dto: CreateMealDto,
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
) {
|
||||
return this.mealsService.createMeal(dto, user.userId);
|
||||
}
|
||||
@Post()
|
||||
async createMeal(@Body() dto: CreateMealDto, @CurrentUser() user: CurrentUserData) {
|
||||
return this.mealsService.createMeal(dto, user.userId);
|
||||
}
|
||||
|
||||
@Post('upload')
|
||||
async uploadMeal(
|
||||
@Body() dto: UploadMealDto,
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
) {
|
||||
return this.mealsService.uploadAndAnalyzeMeal(dto, user.userId);
|
||||
}
|
||||
@Post('upload')
|
||||
async uploadMeal(@Body() dto: UploadMealDto, @CurrentUser() user: CurrentUserData) {
|
||||
return this.mealsService.uploadAndAnalyzeMeal(dto, user.userId);
|
||||
}
|
||||
|
||||
@Get()
|
||||
async getMeals(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Query('date') date?: string,
|
||||
) {
|
||||
return this.mealsService.getMealsByUser(user.userId, date);
|
||||
}
|
||||
@Get()
|
||||
async getMeals(@CurrentUser() user: CurrentUserData, @Query('date') date?: string) {
|
||||
return this.mealsService.getMealsByUser(user.userId, date);
|
||||
}
|
||||
|
||||
@Get('summary')
|
||||
async getDailySummary(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Query('date') date: string,
|
||||
) {
|
||||
return this.mealsService.getDailySummary(user.userId, date);
|
||||
}
|
||||
@Get('summary')
|
||||
async getDailySummary(@CurrentUser() user: CurrentUserData, @Query('date') date: string) {
|
||||
return this.mealsService.getDailySummary(user.userId, date);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async getMealById(
|
||||
@Param('id') id: string,
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
) {
|
||||
return this.mealsService.getMealById(id, user.userId);
|
||||
}
|
||||
@Get(':id')
|
||||
async getMealById(@Param('id') id: string, @CurrentUser() user: CurrentUserData) {
|
||||
return this.mealsService.getMealById(id, user.userId);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
async updateMeal(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateMealDto,
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
) {
|
||||
return this.mealsService.updateMeal(id, dto, user.userId);
|
||||
}
|
||||
@Put(':id')
|
||||
async updateMeal(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateMealDto,
|
||||
@CurrentUser() user: CurrentUserData
|
||||
) {
|
||||
return this.mealsService.updateMeal(id, dto, user.userId);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async deleteMeal(
|
||||
@Param('id') id: string,
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
) {
|
||||
return this.mealsService.deleteMeal(id, user.userId);
|
||||
}
|
||||
@Delete(':id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async deleteMeal(@Param('id') id: string, @CurrentUser() user: CurrentUserData) {
|
||||
return this.mealsService.deleteMeal(id, user.userId);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ import { MealsService } from './meals.service';
|
|||
import { GeminiModule } from '../gemini/gemini.module';
|
||||
|
||||
@Module({
|
||||
imports: [GeminiModule],
|
||||
controllers: [MealsController],
|
||||
providers: [MealsService],
|
||||
imports: [GeminiModule],
|
||||
controllers: [MealsController],
|
||||
providers: [MealsService],
|
||||
})
|
||||
export class MealsModule {}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import { Injectable, Inject, Logger, NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
type Database,
|
||||
meals,
|
||||
eq,
|
||||
and,
|
||||
gte,
|
||||
lte,
|
||||
desc,
|
||||
type Meal as DbMeal,
|
||||
type Database,
|
||||
meals,
|
||||
eq,
|
||||
and,
|
||||
gte,
|
||||
lte,
|
||||
desc,
|
||||
type Meal as DbMeal,
|
||||
} from '@manacore/nutriphi-database';
|
||||
import { DATABASE_TOKEN } from '../database/database.module';
|
||||
import { StorageService } from '../storage/storage.service';
|
||||
|
|
@ -15,267 +15,270 @@ import { GeminiService, NutritionAnalysis } from '../gemini/gemini.service';
|
|||
import { CreateMealDto, UpdateMealDto, UploadMealDto } from './dto/analyze-meal.dto';
|
||||
|
||||
export interface Meal {
|
||||
id: string;
|
||||
user_id: string;
|
||||
food_name: string;
|
||||
image_url?: string;
|
||||
calories: number;
|
||||
protein: number;
|
||||
carbohydrates: number;
|
||||
fat: number;
|
||||
fiber: number;
|
||||
sugar: number;
|
||||
sodium: number;
|
||||
serving_size: string;
|
||||
meal_type?: string;
|
||||
notes?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
id: string;
|
||||
user_id: string;
|
||||
food_name: string;
|
||||
image_url?: string;
|
||||
calories: number;
|
||||
protein: number;
|
||||
carbohydrates: number;
|
||||
fat: number;
|
||||
fiber: number;
|
||||
sugar: number;
|
||||
sodium: number;
|
||||
serving_size: string;
|
||||
meal_type?: string;
|
||||
notes?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface DailySummary {
|
||||
date: string;
|
||||
totalCalories: number;
|
||||
totalProtein: number;
|
||||
totalCarbohydrates: number;
|
||||
totalFat: number;
|
||||
totalFiber: number;
|
||||
totalSugar: number;
|
||||
totalSodium: number;
|
||||
mealCount: number;
|
||||
date: string;
|
||||
totalCalories: number;
|
||||
totalProtein: number;
|
||||
totalCarbohydrates: number;
|
||||
totalFat: number;
|
||||
totalFiber: number;
|
||||
totalSugar: number;
|
||||
totalSodium: number;
|
||||
mealCount: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class MealsService {
|
||||
private readonly logger = new Logger(MealsService.name);
|
||||
private readonly logger = new Logger(MealsService.name);
|
||||
|
||||
constructor(
|
||||
@Inject(DATABASE_TOKEN) private readonly db: Database,
|
||||
private geminiService: GeminiService,
|
||||
private storageService: StorageService,
|
||||
) {}
|
||||
constructor(
|
||||
@Inject(DATABASE_TOKEN) private readonly db: Database,
|
||||
private geminiService: GeminiService,
|
||||
private storageService: StorageService
|
||||
) {}
|
||||
|
||||
private mapDbMealToMeal(dbMeal: DbMeal): Meal {
|
||||
return {
|
||||
id: dbMeal.id,
|
||||
user_id: dbMeal.userId,
|
||||
food_name: dbMeal.foodName,
|
||||
image_url: dbMeal.imageUrl ?? undefined,
|
||||
calories: dbMeal.calories ?? 0,
|
||||
protein: dbMeal.protein ?? 0,
|
||||
carbohydrates: dbMeal.carbohydrates ?? 0,
|
||||
fat: dbMeal.fat ?? 0,
|
||||
fiber: dbMeal.fiber ?? 0,
|
||||
sugar: dbMeal.sugar ?? 0,
|
||||
sodium: dbMeal.sodium ?? 0,
|
||||
serving_size: dbMeal.servingSize ?? '',
|
||||
meal_type: dbMeal.mealType ?? undefined,
|
||||
notes: dbMeal.notes ?? undefined,
|
||||
created_at: dbMeal.createdAt.toISOString(),
|
||||
updated_at: dbMeal.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
private mapDbMealToMeal(dbMeal: DbMeal): Meal {
|
||||
return {
|
||||
id: dbMeal.id,
|
||||
user_id: dbMeal.userId,
|
||||
food_name: dbMeal.foodName,
|
||||
image_url: dbMeal.imageUrl ?? undefined,
|
||||
calories: dbMeal.calories ?? 0,
|
||||
protein: dbMeal.protein ?? 0,
|
||||
carbohydrates: dbMeal.carbohydrates ?? 0,
|
||||
fat: dbMeal.fat ?? 0,
|
||||
fiber: dbMeal.fiber ?? 0,
|
||||
sugar: dbMeal.sugar ?? 0,
|
||||
sodium: dbMeal.sodium ?? 0,
|
||||
serving_size: dbMeal.servingSize ?? '',
|
||||
meal_type: dbMeal.mealType ?? undefined,
|
||||
notes: dbMeal.notes ?? undefined,
|
||||
created_at: dbMeal.createdAt.toISOString(),
|
||||
updated_at: dbMeal.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async analyzeImage(imageBase64: string): Promise<NutritionAnalysis> {
|
||||
return this.geminiService.analyzeFoodImage(imageBase64);
|
||||
}
|
||||
async analyzeImage(imageBase64: string): Promise<NutritionAnalysis> {
|
||||
return this.geminiService.analyzeFoodImage(imageBase64);
|
||||
}
|
||||
|
||||
async analyzeText(description: string): Promise<NutritionAnalysis> {
|
||||
return this.geminiService.analyzeFoodText(description);
|
||||
}
|
||||
async analyzeText(description: string): Promise<NutritionAnalysis> {
|
||||
return this.geminiService.analyzeFoodText(description);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload an image to storage, analyze it, and create a meal
|
||||
*/
|
||||
async uploadAndAnalyzeMeal(dto: UploadMealDto, userId: string): Promise<Meal> {
|
||||
this.logger.log(`Uploading and analyzing meal for user: ${userId}`);
|
||||
/**
|
||||
* Upload an image to storage, analyze it, and create a meal
|
||||
*/
|
||||
async uploadAndAnalyzeMeal(dto: UploadMealDto, userId: string): Promise<Meal> {
|
||||
this.logger.log(`Uploading and analyzing meal for user: ${userId}`);
|
||||
|
||||
// Step 1: Upload image to storage
|
||||
let imageUrl: string | undefined;
|
||||
let storagePath: string | undefined;
|
||||
// Step 1: Upload image to storage
|
||||
let imageUrl: string | undefined;
|
||||
let storagePath: string | undefined;
|
||||
|
||||
try {
|
||||
const uploadResult = await this.storageService.uploadBase64(dto.imageBase64, 'meals');
|
||||
imageUrl = uploadResult.url;
|
||||
storagePath = uploadResult.key;
|
||||
this.logger.log(`Image uploaded: ${storagePath}`);
|
||||
} catch (error) {
|
||||
this.logger.warn('Storage not configured, skipping image upload', error);
|
||||
}
|
||||
try {
|
||||
const uploadResult = await this.storageService.uploadBase64(dto.imageBase64, 'meals');
|
||||
imageUrl = uploadResult.url;
|
||||
storagePath = uploadResult.key;
|
||||
this.logger.log(`Image uploaded: ${storagePath}`);
|
||||
} catch (error) {
|
||||
this.logger.warn('Storage not configured, skipping image upload', error);
|
||||
}
|
||||
|
||||
// Step 2: Analyze the image with Gemini
|
||||
// Extract base64 data without the data URL prefix
|
||||
let base64Data = dto.imageBase64;
|
||||
if (base64Data.includes(',')) {
|
||||
base64Data = base64Data.split(',')[1];
|
||||
}
|
||||
// Step 2: Analyze the image with Gemini
|
||||
// Extract base64 data without the data URL prefix
|
||||
let base64Data = dto.imageBase64;
|
||||
if (base64Data.includes(',')) {
|
||||
base64Data = base64Data.split(',')[1];
|
||||
}
|
||||
|
||||
const analysis = await this.geminiService.analyzeFoodImage(base64Data);
|
||||
const analysis = await this.geminiService.analyzeFoodImage(base64Data);
|
||||
|
||||
// Step 3: Create the meal record
|
||||
const [result] = await this.db.insert(meals).values({
|
||||
userId,
|
||||
foodName: analysis.foodName || 'Unbekanntes Gericht',
|
||||
imageUrl,
|
||||
storagePath,
|
||||
calories: analysis.calories,
|
||||
protein: analysis.protein,
|
||||
carbohydrates: analysis.carbohydrates,
|
||||
fat: analysis.fat,
|
||||
fiber: analysis.fiber,
|
||||
sugar: analysis.sugar,
|
||||
servingSize: analysis.servingSize || '1 Portion',
|
||||
mealType: dto.mealType,
|
||||
analysisStatus: 'completed',
|
||||
}).returning();
|
||||
// Step 3: Create the meal record
|
||||
const [result] = await this.db
|
||||
.insert(meals)
|
||||
.values({
|
||||
userId,
|
||||
foodName: analysis.foodName || 'Unbekanntes Gericht',
|
||||
imageUrl,
|
||||
storagePath,
|
||||
calories: analysis.calories,
|
||||
protein: analysis.protein,
|
||||
carbohydrates: analysis.carbohydrates,
|
||||
fat: analysis.fat,
|
||||
fiber: analysis.fiber,
|
||||
sugar: analysis.sugar,
|
||||
servingSize: analysis.servingSize || '1 Portion',
|
||||
mealType: dto.mealType,
|
||||
analysisStatus: 'completed',
|
||||
})
|
||||
.returning();
|
||||
|
||||
this.logger.log(`Meal created: ${result.id}`);
|
||||
this.logger.log(`Meal created: ${result.id}`);
|
||||
|
||||
return this.mapDbMealToMeal(result);
|
||||
}
|
||||
return this.mapDbMealToMeal(result);
|
||||
}
|
||||
|
||||
async createMeal(dto: CreateMealDto, userId: string): Promise<Meal> {
|
||||
this.logger.log(`Creating meal for user: ${userId}`);
|
||||
async createMeal(dto: CreateMealDto, userId: string): Promise<Meal> {
|
||||
this.logger.log(`Creating meal for user: ${userId}`);
|
||||
|
||||
const [result] = await this.db.insert(meals).values({
|
||||
userId,
|
||||
foodName: dto.foodName,
|
||||
imageUrl: dto.imageUrl,
|
||||
calories: dto.calories,
|
||||
protein: dto.protein,
|
||||
carbohydrates: dto.carbohydrates,
|
||||
fat: dto.fat,
|
||||
fiber: dto.fiber,
|
||||
sugar: dto.sugar,
|
||||
sodium: dto.sodium,
|
||||
servingSize: dto.servingSize,
|
||||
mealType: dto.mealType,
|
||||
notes: dto.notes,
|
||||
}).returning();
|
||||
const [result] = await this.db
|
||||
.insert(meals)
|
||||
.values({
|
||||
userId,
|
||||
foodName: dto.foodName,
|
||||
imageUrl: dto.imageUrl,
|
||||
calories: dto.calories,
|
||||
protein: dto.protein,
|
||||
carbohydrates: dto.carbohydrates,
|
||||
fat: dto.fat,
|
||||
fiber: dto.fiber,
|
||||
sugar: dto.sugar,
|
||||
sodium: dto.sodium,
|
||||
servingSize: dto.servingSize,
|
||||
mealType: dto.mealType,
|
||||
notes: dto.notes,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return this.mapDbMealToMeal(result);
|
||||
}
|
||||
return this.mapDbMealToMeal(result);
|
||||
}
|
||||
|
||||
async getMealsByUser(
|
||||
userId: string,
|
||||
date?: string,
|
||||
): Promise<Meal[]> {
|
||||
this.logger.log(`Fetching meals for user: ${userId}`);
|
||||
async getMealsByUser(userId: string, date?: string): Promise<Meal[]> {
|
||||
this.logger.log(`Fetching meals for user: ${userId}`);
|
||||
|
||||
let query;
|
||||
let query;
|
||||
|
||||
if (date) {
|
||||
const startOfDay = new Date(date);
|
||||
startOfDay.setHours(0, 0, 0, 0);
|
||||
const endOfDay = new Date(date);
|
||||
endOfDay.setHours(23, 59, 59, 999);
|
||||
if (date) {
|
||||
const startOfDay = new Date(date);
|
||||
startOfDay.setHours(0, 0, 0, 0);
|
||||
const endOfDay = new Date(date);
|
||||
endOfDay.setHours(23, 59, 59, 999);
|
||||
|
||||
query = this.db
|
||||
.select()
|
||||
.from(meals)
|
||||
.where(
|
||||
and(
|
||||
eq(meals.userId, userId),
|
||||
gte(meals.createdAt, startOfDay),
|
||||
lte(meals.createdAt, endOfDay)
|
||||
)
|
||||
)
|
||||
.orderBy(desc(meals.createdAt));
|
||||
} else {
|
||||
query = this.db
|
||||
.select()
|
||||
.from(meals)
|
||||
.where(eq(meals.userId, userId))
|
||||
.orderBy(desc(meals.createdAt));
|
||||
}
|
||||
query = this.db
|
||||
.select()
|
||||
.from(meals)
|
||||
.where(
|
||||
and(
|
||||
eq(meals.userId, userId),
|
||||
gte(meals.createdAt, startOfDay),
|
||||
lte(meals.createdAt, endOfDay)
|
||||
)
|
||||
)
|
||||
.orderBy(desc(meals.createdAt));
|
||||
} else {
|
||||
query = this.db
|
||||
.select()
|
||||
.from(meals)
|
||||
.where(eq(meals.userId, userId))
|
||||
.orderBy(desc(meals.createdAt));
|
||||
}
|
||||
|
||||
const results = await query;
|
||||
return results.map(this.mapDbMealToMeal);
|
||||
}
|
||||
const results = await query;
|
||||
return results.map(this.mapDbMealToMeal);
|
||||
}
|
||||
|
||||
async getMealById(id: string, userId: string): Promise<Meal> {
|
||||
const [result] = await this.db
|
||||
.select()
|
||||
.from(meals)
|
||||
.where(and(eq(meals.id, id), eq(meals.userId, userId)));
|
||||
async getMealById(id: string, userId: string): Promise<Meal> {
|
||||
const [result] = await this.db
|
||||
.select()
|
||||
.from(meals)
|
||||
.where(and(eq(meals.id, id), eq(meals.userId, userId)));
|
||||
|
||||
if (!result) {
|
||||
throw new NotFoundException(`Meal with id ${id} not found`);
|
||||
}
|
||||
if (!result) {
|
||||
throw new NotFoundException(`Meal with id ${id} not found`);
|
||||
}
|
||||
|
||||
return this.mapDbMealToMeal(result);
|
||||
}
|
||||
return this.mapDbMealToMeal(result);
|
||||
}
|
||||
|
||||
async updateMeal(id: string, dto: UpdateMealDto, userId: string): Promise<Meal> {
|
||||
this.logger.log(`Updating meal: ${id} for user: ${userId}`);
|
||||
async updateMeal(id: string, dto: UpdateMealDto, userId: string): Promise<Meal> {
|
||||
this.logger.log(`Updating meal: ${id} for user: ${userId}`);
|
||||
|
||||
const updateData: Partial<typeof meals.$inferInsert> = {
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
const updateData: Partial<typeof meals.$inferInsert> = {
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
if (dto.foodName !== undefined) updateData.foodName = dto.foodName;
|
||||
if (dto.calories !== undefined) updateData.calories = dto.calories;
|
||||
if (dto.protein !== undefined) updateData.protein = dto.protein;
|
||||
if (dto.carbohydrates !== undefined) updateData.carbohydrates = dto.carbohydrates;
|
||||
if (dto.fat !== undefined) updateData.fat = dto.fat;
|
||||
if (dto.fiber !== undefined) updateData.fiber = dto.fiber;
|
||||
if (dto.sugar !== undefined) updateData.sugar = dto.sugar;
|
||||
if (dto.sodium !== undefined) updateData.sodium = dto.sodium;
|
||||
if (dto.servingSize !== undefined) updateData.servingSize = dto.servingSize;
|
||||
if (dto.mealType !== undefined) updateData.mealType = dto.mealType;
|
||||
if (dto.notes !== undefined) updateData.notes = dto.notes;
|
||||
if (dto.foodName !== undefined) updateData.foodName = dto.foodName;
|
||||
if (dto.calories !== undefined) updateData.calories = dto.calories;
|
||||
if (dto.protein !== undefined) updateData.protein = dto.protein;
|
||||
if (dto.carbohydrates !== undefined) updateData.carbohydrates = dto.carbohydrates;
|
||||
if (dto.fat !== undefined) updateData.fat = dto.fat;
|
||||
if (dto.fiber !== undefined) updateData.fiber = dto.fiber;
|
||||
if (dto.sugar !== undefined) updateData.sugar = dto.sugar;
|
||||
if (dto.sodium !== undefined) updateData.sodium = dto.sodium;
|
||||
if (dto.servingSize !== undefined) updateData.servingSize = dto.servingSize;
|
||||
if (dto.mealType !== undefined) updateData.mealType = dto.mealType;
|
||||
if (dto.notes !== undefined) updateData.notes = dto.notes;
|
||||
|
||||
const [result] = await this.db
|
||||
.update(meals)
|
||||
.set(updateData)
|
||||
.where(and(eq(meals.id, id), eq(meals.userId, userId)))
|
||||
.returning();
|
||||
const [result] = await this.db
|
||||
.update(meals)
|
||||
.set(updateData)
|
||||
.where(and(eq(meals.id, id), eq(meals.userId, userId)))
|
||||
.returning();
|
||||
|
||||
if (!result) {
|
||||
throw new NotFoundException(`Meal with id ${id} not found`);
|
||||
}
|
||||
if (!result) {
|
||||
throw new NotFoundException(`Meal with id ${id} not found`);
|
||||
}
|
||||
|
||||
return this.mapDbMealToMeal(result);
|
||||
}
|
||||
return this.mapDbMealToMeal(result);
|
||||
}
|
||||
|
||||
async deleteMeal(id: string, userId: string): Promise<void> {
|
||||
this.logger.log(`Deleting meal: ${id} for user: ${userId}`);
|
||||
async deleteMeal(id: string, userId: string): Promise<void> {
|
||||
this.logger.log(`Deleting meal: ${id} for user: ${userId}`);
|
||||
|
||||
const result = await this.db
|
||||
.delete(meals)
|
||||
.where(and(eq(meals.id, id), eq(meals.userId, userId)))
|
||||
.returning();
|
||||
const result = await this.db
|
||||
.delete(meals)
|
||||
.where(and(eq(meals.id, id), eq(meals.userId, userId)))
|
||||
.returning();
|
||||
|
||||
if (result.length === 0) {
|
||||
throw new NotFoundException(`Meal with id ${id} not found`);
|
||||
}
|
||||
}
|
||||
if (result.length === 0) {
|
||||
throw new NotFoundException(`Meal with id ${id} not found`);
|
||||
}
|
||||
}
|
||||
|
||||
async getDailySummary(userId: string, date: string): Promise<DailySummary> {
|
||||
const userMeals = await this.getMealsByUser(userId, date);
|
||||
async getDailySummary(userId: string, date: string): Promise<DailySummary> {
|
||||
const userMeals = await this.getMealsByUser(userId, date);
|
||||
|
||||
const summary: DailySummary = {
|
||||
date,
|
||||
totalCalories: 0,
|
||||
totalProtein: 0,
|
||||
totalCarbohydrates: 0,
|
||||
totalFat: 0,
|
||||
totalFiber: 0,
|
||||
totalSugar: 0,
|
||||
totalSodium: 0,
|
||||
mealCount: userMeals.length,
|
||||
};
|
||||
const summary: DailySummary = {
|
||||
date,
|
||||
totalCalories: 0,
|
||||
totalProtein: 0,
|
||||
totalCarbohydrates: 0,
|
||||
totalFat: 0,
|
||||
totalFiber: 0,
|
||||
totalSugar: 0,
|
||||
totalSodium: 0,
|
||||
mealCount: userMeals.length,
|
||||
};
|
||||
|
||||
for (const meal of userMeals) {
|
||||
summary.totalCalories += meal.calories || 0;
|
||||
summary.totalProtein += meal.protein || 0;
|
||||
summary.totalCarbohydrates += meal.carbohydrates || 0;
|
||||
summary.totalFat += meal.fat || 0;
|
||||
summary.totalFiber += meal.fiber || 0;
|
||||
summary.totalSugar += meal.sugar || 0;
|
||||
summary.totalSodium += meal.sodium || 0;
|
||||
}
|
||||
for (const meal of userMeals) {
|
||||
summary.totalCalories += meal.calories || 0;
|
||||
summary.totalProtein += meal.protein || 0;
|
||||
summary.totalCarbohydrates += meal.carbohydrates || 0;
|
||||
summary.totalFat += meal.fat || 0;
|
||||
summary.totalFiber += meal.fiber || 0;
|
||||
summary.totalSugar += meal.sugar || 0;
|
||||
summary.totalSodium += meal.sodium || 0;
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { StorageService } from './storage.service';
|
|||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [StorageService],
|
||||
exports: [StorageService],
|
||||
providers: [StorageService],
|
||||
exports: [StorageService],
|
||||
})
|
||||
export class StorageModule {}
|
||||
|
|
|
|||
|
|
@ -1,166 +1,160 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import {
|
||||
S3Client,
|
||||
PutObjectCommand,
|
||||
DeleteObjectCommand,
|
||||
GetObjectCommand,
|
||||
S3Client,
|
||||
PutObjectCommand,
|
||||
DeleteObjectCommand,
|
||||
GetObjectCommand,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
export interface UploadResult {
|
||||
key: string;
|
||||
url: string;
|
||||
key: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class StorageService {
|
||||
private readonly logger = new Logger(StorageService.name);
|
||||
private readonly s3Client: S3Client;
|
||||
private readonly bucketName: string;
|
||||
private readonly publicUrl: string;
|
||||
private readonly logger = new Logger(StorageService.name);
|
||||
private readonly s3Client: S3Client;
|
||||
private readonly bucketName: string;
|
||||
private readonly publicUrl: string;
|
||||
|
||||
constructor(private configService: ConfigService) {
|
||||
// Hetzner Object Storage (S3-compatible)
|
||||
const endpoint = this.configService.get<string>('S3_ENDPOINT');
|
||||
const accessKeyId = this.configService.get<string>('S3_ACCESS_KEY_ID');
|
||||
const secretAccessKey = this.configService.get<string>('S3_SECRET_ACCESS_KEY');
|
||||
const region = this.configService.get<string>('S3_REGION') || 'fsn1';
|
||||
this.bucketName = this.configService.get<string>('S3_BUCKET_NAME') || 'nutriphi-meals';
|
||||
this.publicUrl = this.configService.get<string>('S3_PUBLIC_URL') || '';
|
||||
constructor(private configService: ConfigService) {
|
||||
// Hetzner Object Storage (S3-compatible)
|
||||
const endpoint = this.configService.get<string>('S3_ENDPOINT');
|
||||
const accessKeyId = this.configService.get<string>('S3_ACCESS_KEY_ID');
|
||||
const secretAccessKey = this.configService.get<string>('S3_SECRET_ACCESS_KEY');
|
||||
const region = this.configService.get<string>('S3_REGION') || 'fsn1';
|
||||
this.bucketName = this.configService.get<string>('S3_BUCKET_NAME') || 'nutriphi-meals';
|
||||
this.publicUrl = this.configService.get<string>('S3_PUBLIC_URL') || '';
|
||||
|
||||
if (!endpoint || !accessKeyId || !secretAccessKey) {
|
||||
this.logger.warn('S3 configuration incomplete - storage features disabled');
|
||||
this.s3Client = null as unknown as S3Client;
|
||||
return;
|
||||
}
|
||||
if (!endpoint || !accessKeyId || !secretAccessKey) {
|
||||
this.logger.warn('S3 configuration incomplete - storage features disabled');
|
||||
this.s3Client = null as unknown as S3Client;
|
||||
return;
|
||||
}
|
||||
|
||||
this.s3Client = new S3Client({
|
||||
region,
|
||||
endpoint,
|
||||
credentials: {
|
||||
accessKeyId,
|
||||
secretAccessKey,
|
||||
},
|
||||
forcePathStyle: true, // Required for Hetzner Object Storage
|
||||
});
|
||||
this.s3Client = new S3Client({
|
||||
region,
|
||||
endpoint,
|
||||
credentials: {
|
||||
accessKeyId,
|
||||
secretAccessKey,
|
||||
},
|
||||
forcePathStyle: true, // Required for Hetzner Object Storage
|
||||
});
|
||||
|
||||
this.logger.log('Hetzner Object Storage initialized successfully');
|
||||
}
|
||||
this.logger.log('Hetzner Object Storage initialized successfully');
|
||||
}
|
||||
|
||||
private isConfigured(): boolean {
|
||||
return this.s3Client !== null;
|
||||
}
|
||||
private isConfigured(): boolean {
|
||||
return this.s3Client !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a file to R2 storage
|
||||
* @param buffer - File buffer
|
||||
* @param contentType - MIME type of the file
|
||||
* @param folder - Optional folder path (e.g., 'meals', 'avatars')
|
||||
* @returns Upload result with key and public URL
|
||||
*/
|
||||
async upload(
|
||||
buffer: Buffer,
|
||||
contentType: string,
|
||||
folder = 'meals',
|
||||
): Promise<UploadResult> {
|
||||
if (!this.isConfigured()) {
|
||||
throw new Error('R2 storage is not configured');
|
||||
}
|
||||
/**
|
||||
* Upload a file to R2 storage
|
||||
* @param buffer - File buffer
|
||||
* @param contentType - MIME type of the file
|
||||
* @param folder - Optional folder path (e.g., 'meals', 'avatars')
|
||||
* @returns Upload result with key and public URL
|
||||
*/
|
||||
async upload(buffer: Buffer, contentType: string, folder = 'meals'): Promise<UploadResult> {
|
||||
if (!this.isConfigured()) {
|
||||
throw new Error('R2 storage is not configured');
|
||||
}
|
||||
|
||||
const extension = this.getExtensionFromContentType(contentType);
|
||||
const key = `${folder}/${randomUUID()}${extension}`;
|
||||
const extension = this.getExtensionFromContentType(contentType);
|
||||
const key = `${folder}/${randomUUID()}${extension}`;
|
||||
|
||||
this.logger.log(`Uploading file to R2: ${key}`);
|
||||
this.logger.log(`Uploading file to R2: ${key}`);
|
||||
|
||||
await this.s3Client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: this.bucketName,
|
||||
Key: key,
|
||||
Body: buffer,
|
||||
ContentType: contentType,
|
||||
}),
|
||||
);
|
||||
await this.s3Client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: this.bucketName,
|
||||
Key: key,
|
||||
Body: buffer,
|
||||
ContentType: contentType,
|
||||
})
|
||||
);
|
||||
|
||||
const url = this.publicUrl
|
||||
? `${this.publicUrl}/${key}`
|
||||
: await this.getSignedUrl(key);
|
||||
const url = this.publicUrl ? `${this.publicUrl}/${key}` : await this.getSignedUrl(key);
|
||||
|
||||
return { key, url };
|
||||
}
|
||||
return { key, url };
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a base64-encoded image
|
||||
* @param base64Data - Base64 encoded image data (with or without data URI prefix)
|
||||
* @param folder - Optional folder path
|
||||
* @returns Upload result with key and public URL
|
||||
*/
|
||||
async uploadBase64(base64Data: string, folder = 'meals'): Promise<UploadResult> {
|
||||
let data = base64Data;
|
||||
let contentType = 'image/jpeg';
|
||||
/**
|
||||
* Upload a base64-encoded image
|
||||
* @param base64Data - Base64 encoded image data (with or without data URI prefix)
|
||||
* @param folder - Optional folder path
|
||||
* @returns Upload result with key and public URL
|
||||
*/
|
||||
async uploadBase64(base64Data: string, folder = 'meals'): Promise<UploadResult> {
|
||||
let data = base64Data;
|
||||
let contentType = 'image/jpeg';
|
||||
|
||||
// Extract content type from data URI if present
|
||||
if (data.includes(',')) {
|
||||
const matches = data.match(/^data:(.+);base64,/);
|
||||
if (matches) {
|
||||
contentType = matches[1];
|
||||
data = data.split(',')[1];
|
||||
}
|
||||
}
|
||||
// Extract content type from data URI if present
|
||||
if (data.includes(',')) {
|
||||
const matches = data.match(/^data:(.+);base64,/);
|
||||
if (matches) {
|
||||
contentType = matches[1];
|
||||
data = data.split(',')[1];
|
||||
}
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(data, 'base64');
|
||||
return this.upload(buffer, contentType, folder);
|
||||
}
|
||||
const buffer = Buffer.from(data, 'base64');
|
||||
return this.upload(buffer, contentType, folder);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a file from R2 storage
|
||||
* @param key - File key/path in the bucket
|
||||
*/
|
||||
async delete(key: string): Promise<void> {
|
||||
if (!this.isConfigured()) {
|
||||
throw new Error('R2 storage is not configured');
|
||||
}
|
||||
/**
|
||||
* Delete a file from R2 storage
|
||||
* @param key - File key/path in the bucket
|
||||
*/
|
||||
async delete(key: string): Promise<void> {
|
||||
if (!this.isConfigured()) {
|
||||
throw new Error('R2 storage is not configured');
|
||||
}
|
||||
|
||||
this.logger.log(`Deleting file from R2: ${key}`);
|
||||
this.logger.log(`Deleting file from R2: ${key}`);
|
||||
|
||||
await this.s3Client.send(
|
||||
new DeleteObjectCommand({
|
||||
Bucket: this.bucketName,
|
||||
Key: key,
|
||||
}),
|
||||
);
|
||||
}
|
||||
await this.s3Client.send(
|
||||
new DeleteObjectCommand({
|
||||
Bucket: this.bucketName,
|
||||
Key: key,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a signed URL for temporary access to a file
|
||||
* @param key - File key/path in the bucket
|
||||
* @param expiresIn - URL expiration time in seconds (default: 1 hour)
|
||||
* @returns Signed URL
|
||||
*/
|
||||
async getSignedUrl(key: string, expiresIn = 3600): Promise<string> {
|
||||
if (!this.isConfigured()) {
|
||||
throw new Error('R2 storage is not configured');
|
||||
}
|
||||
/**
|
||||
* Get a signed URL for temporary access to a file
|
||||
* @param key - File key/path in the bucket
|
||||
* @param expiresIn - URL expiration time in seconds (default: 1 hour)
|
||||
* @returns Signed URL
|
||||
*/
|
||||
async getSignedUrl(key: string, expiresIn = 3600): Promise<string> {
|
||||
if (!this.isConfigured()) {
|
||||
throw new Error('R2 storage is not configured');
|
||||
}
|
||||
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: this.bucketName,
|
||||
Key: key,
|
||||
});
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: this.bucketName,
|
||||
Key: key,
|
||||
});
|
||||
|
||||
return getSignedUrl(this.s3Client, command, { expiresIn });
|
||||
}
|
||||
return getSignedUrl(this.s3Client, command, { expiresIn });
|
||||
}
|
||||
|
||||
private getExtensionFromContentType(contentType: string): string {
|
||||
const mapping: Record<string, string> = {
|
||||
'image/jpeg': '.jpg',
|
||||
'image/jpg': '.jpg',
|
||||
'image/png': '.png',
|
||||
'image/gif': '.gif',
|
||||
'image/webp': '.webp',
|
||||
'image/heic': '.heic',
|
||||
'image/heif': '.heif',
|
||||
};
|
||||
return mapping[contentType] || '.jpg';
|
||||
}
|
||||
private getExtensionFromContentType(contentType: string): string {
|
||||
const mapping: Record<string, string> = {
|
||||
'image/jpeg': '.jpg',
|
||||
'image/jpg': '.jpg',
|
||||
'image/png': '.png',
|
||||
'image/gif': '.gif',
|
||||
'image/webp': '.webp',
|
||||
'image/heic': '.heic',
|
||||
'image/heif': '.heif',
|
||||
};
|
||||
return mapping[contentType] || '.jpg';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,146 +1,153 @@
|
|||
import { IsString, IsOptional, IsArray, ValidateNested, IsNumber, IsBoolean } from 'class-validator';
|
||||
import {
|
||||
IsString,
|
||||
IsOptional,
|
||||
IsArray,
|
||||
ValidateNested,
|
||||
IsNumber,
|
||||
IsBoolean,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
/**
|
||||
* Local meal data from mobile app
|
||||
*/
|
||||
export class LocalMealDto {
|
||||
@IsNumber()
|
||||
localId: number;
|
||||
@IsNumber()
|
||||
localId: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
cloudId?: string;
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
cloudId?: string;
|
||||
|
||||
@IsString()
|
||||
foodName: string;
|
||||
@IsString()
|
||||
foodName: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
imageUrl?: string;
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
imageUrl?: string;
|
||||
|
||||
@IsOptional()
|
||||
calories?: number;
|
||||
@IsOptional()
|
||||
calories?: number;
|
||||
|
||||
@IsOptional()
|
||||
protein?: number;
|
||||
@IsOptional()
|
||||
protein?: number;
|
||||
|
||||
@IsOptional()
|
||||
carbohydrates?: number;
|
||||
@IsOptional()
|
||||
carbohydrates?: number;
|
||||
|
||||
@IsOptional()
|
||||
fat?: number;
|
||||
@IsOptional()
|
||||
fat?: number;
|
||||
|
||||
@IsOptional()
|
||||
fiber?: number;
|
||||
@IsOptional()
|
||||
fiber?: number;
|
||||
|
||||
@IsOptional()
|
||||
sugar?: number;
|
||||
@IsOptional()
|
||||
sugar?: number;
|
||||
|
||||
@IsOptional()
|
||||
sodium?: number;
|
||||
@IsOptional()
|
||||
sodium?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
servingSize?: string;
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
servingSize?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
mealType?: 'breakfast' | 'lunch' | 'dinner' | 'snack';
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
mealType?: 'breakfast' | 'lunch' | 'dinner' | 'snack';
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
analysisStatus?: string;
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
analysisStatus?: string;
|
||||
|
||||
@IsOptional()
|
||||
healthScore?: number;
|
||||
@IsOptional()
|
||||
healthScore?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
healthCategory?: string;
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
healthCategory?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
|
||||
@IsOptional()
|
||||
userRating?: number;
|
||||
@IsOptional()
|
||||
userRating?: number;
|
||||
|
||||
@IsOptional()
|
||||
foodItems?: any[];
|
||||
@IsOptional()
|
||||
foodItems?: any[];
|
||||
|
||||
@IsNumber()
|
||||
version: number;
|
||||
@IsNumber()
|
||||
version: number;
|
||||
|
||||
@IsString()
|
||||
createdAt: string;
|
||||
@IsString()
|
||||
createdAt: string;
|
||||
|
||||
@IsString()
|
||||
updatedAt: string;
|
||||
@IsString()
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Push request - local changes to server
|
||||
*/
|
||||
export class SyncPushDto {
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => LocalMealDto)
|
||||
meals: LocalMealDto[];
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => LocalMealDto)
|
||||
meals: LocalMealDto[];
|
||||
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
deletedIds: string[];
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
deletedIds: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
lastSyncAt?: string;
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
lastSyncAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Push response
|
||||
*/
|
||||
export interface SyncPushResponse {
|
||||
created: { localId: number; cloudId: string }[];
|
||||
updated: string[];
|
||||
conflicts: ConflictInfo[];
|
||||
serverTime: string;
|
||||
created: { localId: number; cloudId: string }[];
|
||||
updated: string[];
|
||||
conflicts: ConflictInfo[];
|
||||
serverTime: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Conflict information
|
||||
*/
|
||||
export interface ConflictInfo {
|
||||
cloudId: string;
|
||||
localVersion: number;
|
||||
serverVersion: number;
|
||||
serverData: any;
|
||||
message: string;
|
||||
cloudId: string;
|
||||
localVersion: number;
|
||||
serverVersion: number;
|
||||
serverData: any;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull query parameters
|
||||
*/
|
||||
export class SyncPullQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
since?: string;
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
since?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull response
|
||||
*/
|
||||
export interface SyncPullResponse {
|
||||
meals: any[];
|
||||
deletedIds: string[];
|
||||
serverTime: string;
|
||||
meals: any[];
|
||||
deletedIds: string[];
|
||||
serverTime: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync status response
|
||||
*/
|
||||
export interface SyncStatusResponse {
|
||||
lastSyncAt: string | null;
|
||||
pendingChanges: number;
|
||||
serverTime: string;
|
||||
lastSyncAt: string | null;
|
||||
pendingChanges: number;
|
||||
serverTime: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import { Controller, Get, Post, Body, Query, UseGuards } from '@nestjs/common';
|
||||
import { SyncService } from './sync.service';
|
||||
import {
|
||||
SyncPushDto,
|
||||
SyncPushResponse,
|
||||
SyncPullQueryDto,
|
||||
SyncPullResponse,
|
||||
SyncStatusResponse,
|
||||
SyncPushDto,
|
||||
SyncPushResponse,
|
||||
SyncPullQueryDto,
|
||||
SyncPullResponse,
|
||||
SyncStatusResponse,
|
||||
} from './dto/sync.dto';
|
||||
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
|
||||
import { CurrentUser, CurrentUserData } from '../common/decorators/current-user.decorator';
|
||||
|
|
@ -13,38 +13,38 @@ import { CurrentUser, CurrentUserData } from '../common/decorators/current-user.
|
|||
@Controller('sync')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class SyncController {
|
||||
constructor(private readonly syncService: SyncService) {}
|
||||
constructor(private readonly syncService: SyncService) {}
|
||||
|
||||
/**
|
||||
* Push local changes to server
|
||||
* POST /api/sync/push
|
||||
*/
|
||||
@Post('push')
|
||||
async pushChanges(
|
||||
@Body() dto: SyncPushDto,
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
): Promise<SyncPushResponse> {
|
||||
return this.syncService.pushChanges(user.userId, dto);
|
||||
}
|
||||
/**
|
||||
* Push local changes to server
|
||||
* POST /api/sync/push
|
||||
*/
|
||||
@Post('push')
|
||||
async pushChanges(
|
||||
@Body() dto: SyncPushDto,
|
||||
@CurrentUser() user: CurrentUserData
|
||||
): Promise<SyncPushResponse> {
|
||||
return this.syncService.pushChanges(user.userId, dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull changes from server
|
||||
* GET /api/sync/pull?since=2024-01-01T00:00:00Z
|
||||
*/
|
||||
@Get('pull')
|
||||
async pullChanges(
|
||||
@Query() query: SyncPullQueryDto,
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
): Promise<SyncPullResponse> {
|
||||
return this.syncService.pullChanges(user.userId, query.since);
|
||||
}
|
||||
/**
|
||||
* Pull changes from server
|
||||
* GET /api/sync/pull?since=2024-01-01T00:00:00Z
|
||||
*/
|
||||
@Get('pull')
|
||||
async pullChanges(
|
||||
@Query() query: SyncPullQueryDto,
|
||||
@CurrentUser() user: CurrentUserData
|
||||
): Promise<SyncPullResponse> {
|
||||
return this.syncService.pullChanges(user.userId, query.since);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sync status
|
||||
* GET /api/sync/status
|
||||
*/
|
||||
@Get('status')
|
||||
async getStatus(@CurrentUser() user: CurrentUserData): Promise<SyncStatusResponse> {
|
||||
return this.syncService.getStatus(user.userId);
|
||||
}
|
||||
/**
|
||||
* Get sync status
|
||||
* GET /api/sync/status
|
||||
*/
|
||||
@Get('status')
|
||||
async getStatus(@CurrentUser() user: CurrentUserData): Promise<SyncStatusResponse> {
|
||||
return this.syncService.getStatus(user.userId);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ import { SyncController } from './sync.controller';
|
|||
import { SyncService } from './sync.service';
|
||||
|
||||
@Module({
|
||||
controllers: [SyncController],
|
||||
providers: [SyncService],
|
||||
exports: [SyncService],
|
||||
controllers: [SyncController],
|
||||
providers: [SyncService],
|
||||
exports: [SyncService],
|
||||
})
|
||||
export class SyncModule {}
|
||||
|
|
|
|||
|
|
@ -1,251 +1,246 @@
|
|||
import { Injectable, Inject, Logger } from '@nestjs/common';
|
||||
import {
|
||||
type Database,
|
||||
meals,
|
||||
eq,
|
||||
and,
|
||||
gt,
|
||||
type Meal as DbMeal,
|
||||
type Database,
|
||||
meals,
|
||||
eq,
|
||||
and,
|
||||
gt,
|
||||
type Meal as DbMeal,
|
||||
} from '@manacore/nutriphi-database';
|
||||
import { DATABASE_TOKEN } from '../database/database.module';
|
||||
import {
|
||||
LocalMealDto,
|
||||
SyncPushDto,
|
||||
SyncPushResponse,
|
||||
SyncPullResponse,
|
||||
SyncStatusResponse,
|
||||
ConflictInfo,
|
||||
LocalMealDto,
|
||||
SyncPushDto,
|
||||
SyncPushResponse,
|
||||
SyncPullResponse,
|
||||
SyncStatusResponse,
|
||||
ConflictInfo,
|
||||
} from './dto/sync.dto';
|
||||
|
||||
@Injectable()
|
||||
export class SyncService {
|
||||
private readonly logger = new Logger(SyncService.name);
|
||||
private readonly logger = new Logger(SyncService.name);
|
||||
|
||||
constructor(@Inject(DATABASE_TOKEN) private readonly db: Database) {}
|
||||
constructor(@Inject(DATABASE_TOKEN) private readonly db: Database) {}
|
||||
|
||||
/**
|
||||
* Push local changes to server
|
||||
*/
|
||||
async pushChanges(userId: string, dto: SyncPushDto): Promise<SyncPushResponse> {
|
||||
this.logger.log(`Processing sync push for user: ${userId}, ${dto.meals.length} meals`);
|
||||
/**
|
||||
* Push local changes to server
|
||||
*/
|
||||
async pushChanges(userId: string, dto: SyncPushDto): Promise<SyncPushResponse> {
|
||||
this.logger.log(`Processing sync push for user: ${userId}, ${dto.meals.length} meals`);
|
||||
|
||||
const created: { localId: number; cloudId: string }[] = [];
|
||||
const updated: string[] = [];
|
||||
const conflicts: ConflictInfo[] = [];
|
||||
const serverTime = new Date().toISOString();
|
||||
const created: { localId: number; cloudId: string }[] = [];
|
||||
const updated: string[] = [];
|
||||
const conflicts: ConflictInfo[] = [];
|
||||
const serverTime = new Date().toISOString();
|
||||
|
||||
// Process each meal
|
||||
for (const localMeal of dto.meals) {
|
||||
try {
|
||||
if (localMeal.cloudId) {
|
||||
// Update existing meal
|
||||
const result = await this.updateExistingMeal(userId, localMeal);
|
||||
if (result.conflict) {
|
||||
conflicts.push(result.conflict);
|
||||
} else if (result.updated) {
|
||||
updated.push(localMeal.cloudId);
|
||||
}
|
||||
} else {
|
||||
// Create new meal
|
||||
const cloudId = await this.createNewMeal(userId, localMeal);
|
||||
created.push({ localId: localMeal.localId, cloudId });
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`Error processing meal ${localMeal.localId}:`, error);
|
||||
}
|
||||
}
|
||||
// Process each meal
|
||||
for (const localMeal of dto.meals) {
|
||||
try {
|
||||
if (localMeal.cloudId) {
|
||||
// Update existing meal
|
||||
const result = await this.updateExistingMeal(userId, localMeal);
|
||||
if (result.conflict) {
|
||||
conflicts.push(result.conflict);
|
||||
} else if (result.updated) {
|
||||
updated.push(localMeal.cloudId);
|
||||
}
|
||||
} else {
|
||||
// Create new meal
|
||||
const cloudId = await this.createNewMeal(userId, localMeal);
|
||||
created.push({ localId: localMeal.localId, cloudId });
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`Error processing meal ${localMeal.localId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Process deletions
|
||||
for (const cloudId of dto.deletedIds) {
|
||||
try {
|
||||
await this.db
|
||||
.delete(meals)
|
||||
.where(and(eq(meals.id, cloudId), eq(meals.userId, userId)));
|
||||
this.logger.log(`Deleted meal: ${cloudId}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Error deleting meal ${cloudId}:`, error);
|
||||
}
|
||||
}
|
||||
// Process deletions
|
||||
for (const cloudId of dto.deletedIds) {
|
||||
try {
|
||||
await this.db.delete(meals).where(and(eq(meals.id, cloudId), eq(meals.userId, userId)));
|
||||
this.logger.log(`Deleted meal: ${cloudId}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Error deleting meal ${cloudId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return { created, updated, conflicts, serverTime };
|
||||
}
|
||||
return { created, updated, conflicts, serverTime };
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull changes from server since given timestamp
|
||||
*/
|
||||
async pullChanges(userId: string, since?: string): Promise<SyncPullResponse> {
|
||||
this.logger.log(`Processing sync pull for user: ${userId}, since: ${since}`);
|
||||
/**
|
||||
* Pull changes from server since given timestamp
|
||||
*/
|
||||
async pullChanges(userId: string, since?: string): Promise<SyncPullResponse> {
|
||||
this.logger.log(`Processing sync pull for user: ${userId}, since: ${since}`);
|
||||
|
||||
const serverTime = new Date().toISOString();
|
||||
const serverTime = new Date().toISOString();
|
||||
|
||||
let query;
|
||||
if (since) {
|
||||
const sinceDate = new Date(since);
|
||||
query = this.db
|
||||
.select()
|
||||
.from(meals)
|
||||
.where(and(eq(meals.userId, userId), gt(meals.updatedAt, sinceDate)));
|
||||
} else {
|
||||
// Full sync - get all meals
|
||||
query = this.db.select().from(meals).where(eq(meals.userId, userId));
|
||||
}
|
||||
let query;
|
||||
if (since) {
|
||||
const sinceDate = new Date(since);
|
||||
query = this.db
|
||||
.select()
|
||||
.from(meals)
|
||||
.where(and(eq(meals.userId, userId), gt(meals.updatedAt, sinceDate)));
|
||||
} else {
|
||||
// Full sync - get all meals
|
||||
query = this.db.select().from(meals).where(eq(meals.userId, userId));
|
||||
}
|
||||
|
||||
const results = await query;
|
||||
const results = await query;
|
||||
|
||||
const mappedMeals = results.map((meal) => this.mapDbMealToSync(meal));
|
||||
const mappedMeals = results.map((meal) => this.mapDbMealToSync(meal));
|
||||
|
||||
return {
|
||||
meals: mappedMeals,
|
||||
deletedIds: [], // TODO: Implement soft deletes to track deleted meals
|
||||
serverTime,
|
||||
};
|
||||
}
|
||||
return {
|
||||
meals: mappedMeals,
|
||||
deletedIds: [], // TODO: Implement soft deletes to track deleted meals
|
||||
serverTime,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sync status
|
||||
*/
|
||||
async getStatus(userId: string): Promise<SyncStatusResponse> {
|
||||
const serverTime = new Date().toISOString();
|
||||
/**
|
||||
* Get sync status
|
||||
*/
|
||||
async getStatus(userId: string): Promise<SyncStatusResponse> {
|
||||
const serverTime = new Date().toISOString();
|
||||
|
||||
// Count user's meals
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(meals)
|
||||
.where(eq(meals.userId, userId));
|
||||
// Count user's meals
|
||||
const result = await this.db.select().from(meals).where(eq(meals.userId, userId));
|
||||
|
||||
return {
|
||||
lastSyncAt: null, // Could be stored in a user preferences table
|
||||
pendingChanges: 0,
|
||||
serverTime,
|
||||
};
|
||||
}
|
||||
return {
|
||||
lastSyncAt: null, // Could be stored in a user preferences table
|
||||
pendingChanges: 0,
|
||||
serverTime,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new meal from local data
|
||||
*/
|
||||
private async createNewMeal(userId: string, localMeal: LocalMealDto): Promise<string> {
|
||||
const [result] = await this.db
|
||||
.insert(meals)
|
||||
.values({
|
||||
userId,
|
||||
foodName: localMeal.foodName,
|
||||
imageUrl: localMeal.imageUrl,
|
||||
calories: localMeal.calories ?? 0,
|
||||
protein: localMeal.protein ?? 0,
|
||||
carbohydrates: localMeal.carbohydrates ?? 0,
|
||||
fat: localMeal.fat ?? 0,
|
||||
fiber: localMeal.fiber ?? 0,
|
||||
sugar: localMeal.sugar ?? 0,
|
||||
sodium: localMeal.sodium ?? 0,
|
||||
servingSize: localMeal.servingSize,
|
||||
mealType: localMeal.mealType,
|
||||
analysisStatus: localMeal.analysisStatus ?? 'completed',
|
||||
healthScore: localMeal.healthScore,
|
||||
healthCategory: localMeal.healthCategory,
|
||||
notes: localMeal.notes,
|
||||
userRating: localMeal.userRating,
|
||||
foodItems: localMeal.foodItems ?? [],
|
||||
createdAt: new Date(localMeal.createdAt),
|
||||
updatedAt: new Date(localMeal.updatedAt),
|
||||
})
|
||||
.returning();
|
||||
/**
|
||||
* Create a new meal from local data
|
||||
*/
|
||||
private async createNewMeal(userId: string, localMeal: LocalMealDto): Promise<string> {
|
||||
const [result] = await this.db
|
||||
.insert(meals)
|
||||
.values({
|
||||
userId,
|
||||
foodName: localMeal.foodName,
|
||||
imageUrl: localMeal.imageUrl,
|
||||
calories: localMeal.calories ?? 0,
|
||||
protein: localMeal.protein ?? 0,
|
||||
carbohydrates: localMeal.carbohydrates ?? 0,
|
||||
fat: localMeal.fat ?? 0,
|
||||
fiber: localMeal.fiber ?? 0,
|
||||
sugar: localMeal.sugar ?? 0,
|
||||
sodium: localMeal.sodium ?? 0,
|
||||
servingSize: localMeal.servingSize,
|
||||
mealType: localMeal.mealType,
|
||||
analysisStatus: localMeal.analysisStatus ?? 'completed',
|
||||
healthScore: localMeal.healthScore,
|
||||
healthCategory: localMeal.healthCategory,
|
||||
notes: localMeal.notes,
|
||||
userRating: localMeal.userRating,
|
||||
foodItems: localMeal.foodItems ?? [],
|
||||
createdAt: new Date(localMeal.createdAt),
|
||||
updatedAt: new Date(localMeal.updatedAt),
|
||||
})
|
||||
.returning();
|
||||
|
||||
this.logger.log(`Created meal: ${result.id} for local: ${localMeal.localId}`);
|
||||
return result.id;
|
||||
}
|
||||
this.logger.log(`Created meal: ${result.id} for local: ${localMeal.localId}`);
|
||||
return result.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update existing meal, checking for conflicts
|
||||
*/
|
||||
private async updateExistingMeal(
|
||||
userId: string,
|
||||
localMeal: LocalMealDto,
|
||||
): Promise<{ updated: boolean; conflict?: ConflictInfo }> {
|
||||
// Get current server version
|
||||
const [serverMeal] = await this.db
|
||||
.select()
|
||||
.from(meals)
|
||||
.where(and(eq(meals.id, localMeal.cloudId!), eq(meals.userId, userId)));
|
||||
/**
|
||||
* Update existing meal, checking for conflicts
|
||||
*/
|
||||
private async updateExistingMeal(
|
||||
userId: string,
|
||||
localMeal: LocalMealDto
|
||||
): Promise<{ updated: boolean; conflict?: ConflictInfo }> {
|
||||
// Get current server version
|
||||
const [serverMeal] = await this.db
|
||||
.select()
|
||||
.from(meals)
|
||||
.where(and(eq(meals.id, localMeal.cloudId!), eq(meals.userId, userId)));
|
||||
|
||||
if (!serverMeal) {
|
||||
this.logger.warn(`Meal not found: ${localMeal.cloudId}`);
|
||||
return { updated: false };
|
||||
}
|
||||
if (!serverMeal) {
|
||||
this.logger.warn(`Meal not found: ${localMeal.cloudId}`);
|
||||
return { updated: false };
|
||||
}
|
||||
|
||||
// Simple last-write-wins strategy
|
||||
// In production, you might want more sophisticated conflict resolution
|
||||
const localUpdateTime = new Date(localMeal.updatedAt);
|
||||
const serverUpdateTime = serverMeal.updatedAt;
|
||||
// Simple last-write-wins strategy
|
||||
// In production, you might want more sophisticated conflict resolution
|
||||
const localUpdateTime = new Date(localMeal.updatedAt);
|
||||
const serverUpdateTime = serverMeal.updatedAt;
|
||||
|
||||
// If local is newer, update server
|
||||
if (localUpdateTime >= serverUpdateTime) {
|
||||
await this.db
|
||||
.update(meals)
|
||||
.set({
|
||||
foodName: localMeal.foodName,
|
||||
imageUrl: localMeal.imageUrl,
|
||||
calories: localMeal.calories ?? 0,
|
||||
protein: localMeal.protein ?? 0,
|
||||
carbohydrates: localMeal.carbohydrates ?? 0,
|
||||
fat: localMeal.fat ?? 0,
|
||||
fiber: localMeal.fiber ?? 0,
|
||||
sugar: localMeal.sugar ?? 0,
|
||||
sodium: localMeal.sodium ?? 0,
|
||||
servingSize: localMeal.servingSize,
|
||||
mealType: localMeal.mealType,
|
||||
analysisStatus: localMeal.analysisStatus,
|
||||
healthScore: localMeal.healthScore,
|
||||
healthCategory: localMeal.healthCategory,
|
||||
notes: localMeal.notes,
|
||||
userRating: localMeal.userRating,
|
||||
foodItems: localMeal.foodItems ?? [],
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(meals.id, localMeal.cloudId!));
|
||||
// If local is newer, update server
|
||||
if (localUpdateTime >= serverUpdateTime) {
|
||||
await this.db
|
||||
.update(meals)
|
||||
.set({
|
||||
foodName: localMeal.foodName,
|
||||
imageUrl: localMeal.imageUrl,
|
||||
calories: localMeal.calories ?? 0,
|
||||
protein: localMeal.protein ?? 0,
|
||||
carbohydrates: localMeal.carbohydrates ?? 0,
|
||||
fat: localMeal.fat ?? 0,
|
||||
fiber: localMeal.fiber ?? 0,
|
||||
sugar: localMeal.sugar ?? 0,
|
||||
sodium: localMeal.sodium ?? 0,
|
||||
servingSize: localMeal.servingSize,
|
||||
mealType: localMeal.mealType,
|
||||
analysisStatus: localMeal.analysisStatus,
|
||||
healthScore: localMeal.healthScore,
|
||||
healthCategory: localMeal.healthCategory,
|
||||
notes: localMeal.notes,
|
||||
userRating: localMeal.userRating,
|
||||
foodItems: localMeal.foodItems ?? [],
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(meals.id, localMeal.cloudId!));
|
||||
|
||||
this.logger.log(`Updated meal: ${localMeal.cloudId}`);
|
||||
return { updated: true };
|
||||
}
|
||||
this.logger.log(`Updated meal: ${localMeal.cloudId}`);
|
||||
return { updated: true };
|
||||
}
|
||||
|
||||
// Server is newer - report conflict
|
||||
return {
|
||||
updated: false,
|
||||
conflict: {
|
||||
cloudId: localMeal.cloudId!,
|
||||
localVersion: localMeal.version,
|
||||
serverVersion: 1, // Would need version tracking in DB
|
||||
serverData: this.mapDbMealToSync(serverMeal),
|
||||
message: 'Server has newer data',
|
||||
},
|
||||
};
|
||||
}
|
||||
// Server is newer - report conflict
|
||||
return {
|
||||
updated: false,
|
||||
conflict: {
|
||||
cloudId: localMeal.cloudId!,
|
||||
localVersion: localMeal.version,
|
||||
serverVersion: 1, // Would need version tracking in DB
|
||||
serverData: this.mapDbMealToSync(serverMeal),
|
||||
message: 'Server has newer data',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map database meal to sync format
|
||||
*/
|
||||
private mapDbMealToSync(meal: DbMeal): any {
|
||||
return {
|
||||
cloudId: meal.id,
|
||||
userId: meal.userId,
|
||||
foodName: meal.foodName,
|
||||
imageUrl: meal.imageUrl,
|
||||
calories: meal.calories,
|
||||
protein: meal.protein,
|
||||
carbohydrates: meal.carbohydrates,
|
||||
fat: meal.fat,
|
||||
fiber: meal.fiber,
|
||||
sugar: meal.sugar,
|
||||
sodium: meal.sodium,
|
||||
servingSize: meal.servingSize,
|
||||
mealType: meal.mealType,
|
||||
analysisStatus: meal.analysisStatus,
|
||||
healthScore: meal.healthScore,
|
||||
healthCategory: meal.healthCategory,
|
||||
notes: meal.notes,
|
||||
userRating: meal.userRating,
|
||||
foodItems: meal.foodItems,
|
||||
createdAt: meal.createdAt.toISOString(),
|
||||
updatedAt: meal.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Map database meal to sync format
|
||||
*/
|
||||
private mapDbMealToSync(meal: DbMeal): any {
|
||||
return {
|
||||
cloudId: meal.id,
|
||||
userId: meal.userId,
|
||||
foodName: meal.foodName,
|
||||
imageUrl: meal.imageUrl,
|
||||
calories: meal.calories,
|
||||
protein: meal.protein,
|
||||
carbohydrates: meal.carbohydrates,
|
||||
fat: meal.fat,
|
||||
fiber: meal.fiber,
|
||||
sugar: meal.sugar,
|
||||
sodium: meal.sodium,
|
||||
servingSize: meal.servingSize,
|
||||
mealType: meal.mealType,
|
||||
analysisStatus: meal.analysisStatus,
|
||||
healthScore: meal.healthScore,
|
||||
healthCategory: meal.healthCategory,
|
||||
notes: meal.notes,
|
||||
userRating: meal.userRating,
|
||||
foodItems: meal.foodItems,
|
||||
createdAt: meal.createdAt.toISOString(),
|
||||
updatedAt: meal.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,21 +1,21 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2021",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
}
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2021",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,26 +1,26 @@
|
|||
{
|
||||
"name": "@nutriphi/landing",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"start": "astro dev",
|
||||
"build": "astro check && astro build",
|
||||
"preview": "astro preview",
|
||||
"astro": "astro",
|
||||
"type-check": "astro check"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/check": "^0.9.0",
|
||||
"@astrojs/sitemap": "^3.2.1",
|
||||
"@manacore/shared-landing-ui": "workspace:*",
|
||||
"astro": "^5.16.0",
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/tailwind": "^6.0.0",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"tailwindcss": "^3.4.17"
|
||||
}
|
||||
"name": "@nutriphi/landing",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"start": "astro dev",
|
||||
"build": "astro check && astro build",
|
||||
"preview": "astro preview",
|
||||
"astro": "astro",
|
||||
"type-check": "astro check"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/check": "^0.9.0",
|
||||
"@astrojs/sitemap": "^3.2.1",
|
||||
"@manacore/shared-landing-ui": "workspace:*",
|
||||
"astro": "^5.16.0",
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/tailwind": "^6.0.0",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"tailwindcss": "^3.4.17"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,78 +1,82 @@
|
|||
---
|
||||
const footerLinks = {
|
||||
product: [
|
||||
{ href: '#features', label: 'Features' },
|
||||
{ href: '#pricing', label: 'Preise' },
|
||||
{ href: '#faq', label: 'FAQ' }
|
||||
],
|
||||
legal: [
|
||||
{ href: '/privacy', label: 'Datenschutz' },
|
||||
{ href: '/terms', label: 'AGB' },
|
||||
{ href: '/imprint', label: 'Impressum' }
|
||||
]
|
||||
product: [
|
||||
{ href: '#features', label: 'Features' },
|
||||
{ href: '#pricing', label: 'Preise' },
|
||||
{ href: '#faq', label: 'FAQ' },
|
||||
],
|
||||
legal: [
|
||||
{ href: '/privacy', label: 'Datenschutz' },
|
||||
{ href: '/terms', label: 'AGB' },
|
||||
{ href: '/imprint', label: 'Impressum' },
|
||||
],
|
||||
};
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
---
|
||||
|
||||
<footer class="bg-background-card border-t border-border">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-8">
|
||||
<!-- Brand -->
|
||||
<div class="col-span-1 md:col-span-2">
|
||||
<a href="/" class="flex items-center gap-2 mb-4">
|
||||
<span class="text-2xl">🥗</span>
|
||||
<span class="font-bold text-xl text-text-primary">Nutriphi</span>
|
||||
</a>
|
||||
<p class="text-text-secondary text-sm max-w-md">
|
||||
Dein KI-gestützter Ernährungs-Tracker. Fotografiere deine Mahlzeiten
|
||||
und erhalte sofort detaillierte Nährwertinformationen.
|
||||
</p>
|
||||
</div>
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-8">
|
||||
<!-- Brand -->
|
||||
<div class="col-span-1 md:col-span-2">
|
||||
<a href="/" class="flex items-center gap-2 mb-4">
|
||||
<span class="text-2xl">🥗</span>
|
||||
<span class="font-bold text-xl text-text-primary">Nutriphi</span>
|
||||
</a>
|
||||
<p class="text-text-secondary text-sm max-w-md">
|
||||
Dein KI-gestützter Ernährungs-Tracker. Fotografiere deine Mahlzeiten und erhalte sofort
|
||||
detaillierte Nährwertinformationen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Product Links -->
|
||||
<div>
|
||||
<h3 class="font-semibold text-text-primary mb-4">Produkt</h3>
|
||||
<ul class="space-y-2">
|
||||
{footerLinks.product.map(link => (
|
||||
<li>
|
||||
<a
|
||||
href={link.href}
|
||||
class="text-text-secondary hover:text-text-primary transition-colors text-sm"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<!-- Product Links -->
|
||||
<div>
|
||||
<h3 class="font-semibold text-text-primary mb-4">Produkt</h3>
|
||||
<ul class="space-y-2">
|
||||
{
|
||||
footerLinks.product.map((link) => (
|
||||
<li>
|
||||
<a
|
||||
href={link.href}
|
||||
class="text-text-secondary hover:text-text-primary transition-colors text-sm"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Legal Links -->
|
||||
<div>
|
||||
<h3 class="font-semibold text-text-primary mb-4">Rechtliches</h3>
|
||||
<ul class="space-y-2">
|
||||
{footerLinks.legal.map(link => (
|
||||
<li>
|
||||
<a
|
||||
href={link.href}
|
||||
class="text-text-secondary hover:text-text-primary transition-colors text-sm"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Legal Links -->
|
||||
<div>
|
||||
<h3 class="font-semibold text-text-primary mb-4">Rechtliches</h3>
|
||||
<ul class="space-y-2">
|
||||
{
|
||||
footerLinks.legal.map((link) => (
|
||||
<li>
|
||||
<a
|
||||
href={link.href}
|
||||
class="text-text-secondary hover:text-text-primary transition-colors text-sm"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom -->
|
||||
<div class="mt-12 pt-8 border-t border-border flex flex-col sm:flex-row justify-between items-center gap-4">
|
||||
<p class="text-text-muted text-sm">
|
||||
© {currentYear} Nutriphi. Teil des Mana Core Ökosystems.
|
||||
</p>
|
||||
<p class="text-text-muted text-sm">
|
||||
Made with 💚 in Germany
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Bottom -->
|
||||
<div
|
||||
class="mt-12 pt-8 border-t border-border flex flex-col sm:flex-row justify-between items-center gap-4"
|
||||
>
|
||||
<p class="text-text-muted text-sm">
|
||||
© {currentYear} Nutriphi. Teil des Mana Core Ökosystems.
|
||||
</p>
|
||||
<p class="text-text-muted text-sm">Made with 💚 in Germany</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
|
|
|||
|
|
@ -1,84 +1,89 @@
|
|||
---
|
||||
const navLinks = [
|
||||
{ href: '#features', label: 'Features' },
|
||||
{ href: '#how-it-works', label: 'So funktioniert\'s' },
|
||||
{ href: '#pricing', label: 'Preise' },
|
||||
{ href: '#faq', label: 'FAQ' }
|
||||
{ href: '#features', label: 'Features' },
|
||||
{ href: '#how-it-works', label: "So funktioniert's" },
|
||||
{ href: '#pricing', label: 'Preise' },
|
||||
{ href: '#faq', label: 'FAQ' },
|
||||
];
|
||||
---
|
||||
|
||||
<nav class="fixed top-0 left-0 right-0 z-50 bg-background-page/80 backdrop-blur-lg border-b border-border">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center justify-between h-16">
|
||||
<!-- Logo -->
|
||||
<a href="/" class="flex items-center gap-2">
|
||||
<span class="text-2xl">🥗</span>
|
||||
<span class="font-bold text-xl text-text-primary">Nutriphi</span>
|
||||
</a>
|
||||
<nav
|
||||
class="fixed top-0 left-0 right-0 z-50 bg-background-page/80 backdrop-blur-lg border-b border-border"
|
||||
>
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center justify-between h-16">
|
||||
<!-- Logo -->
|
||||
<a href="/" class="flex items-center gap-2">
|
||||
<span class="text-2xl">🥗</span>
|
||||
<span class="font-bold text-xl text-text-primary">Nutriphi</span>
|
||||
</a>
|
||||
|
||||
<!-- Desktop Navigation -->
|
||||
<div class="hidden md:flex items-center gap-8">
|
||||
{navLinks.map(link => (
|
||||
<a
|
||||
href={link.href}
|
||||
class="text-text-secondary hover:text-text-primary transition-colors text-sm font-medium"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
<!-- Desktop Navigation -->
|
||||
<div class="hidden md:flex items-center gap-8">
|
||||
{
|
||||
navLinks.map((link) => (
|
||||
<a
|
||||
href={link.href}
|
||||
class="text-text-secondary hover:text-text-primary transition-colors text-sm font-medium"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- CTA Button -->
|
||||
<div class="flex items-center gap-4">
|
||||
<a
|
||||
href="#download"
|
||||
class="btn-primary text-sm px-4 py-2"
|
||||
>
|
||||
App herunterladen
|
||||
</a>
|
||||
<!-- CTA Button -->
|
||||
<div class="flex items-center gap-4">
|
||||
<a href="#download" class="btn-primary text-sm px-4 py-2"> App herunterladen </a>
|
||||
|
||||
<!-- Mobile Menu Button -->
|
||||
<button
|
||||
type="button"
|
||||
class="md:hidden p-2 text-text-secondary hover:text-text-primary"
|
||||
aria-label="Menu"
|
||||
id="mobile-menu-button"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Mobile Menu Button -->
|
||||
<button
|
||||
type="button"
|
||||
class="md:hidden p-2 text-text-secondary hover:text-text-primary"
|
||||
aria-label="Menu"
|
||||
id="mobile-menu-button"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 6h16M4 12h16M4 18h16"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Menu -->
|
||||
<div class="hidden md:hidden" id="mobile-menu">
|
||||
<div class="px-4 py-4 space-y-2 bg-background-card border-t border-border">
|
||||
{navLinks.map(link => (
|
||||
<a
|
||||
href={link.href}
|
||||
class="block px-4 py-2 text-text-secondary hover:text-text-primary hover:bg-background-card-hover rounded-lg transition-colors"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Mobile Menu -->
|
||||
<div class="hidden md:hidden" id="mobile-menu">
|
||||
<div class="px-4 py-4 space-y-2 bg-background-card border-t border-border">
|
||||
{
|
||||
navLinks.map((link) => (
|
||||
<a
|
||||
href={link.href}
|
||||
class="block px-4 py-2 text-text-secondary hover:text-text-primary hover:bg-background-card-hover rounded-lg transition-colors"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<script>
|
||||
const mobileMenuButton = document.getElementById('mobile-menu-button');
|
||||
const mobileMenu = document.getElementById('mobile-menu');
|
||||
const mobileMenuButton = document.getElementById('mobile-menu-button');
|
||||
const mobileMenu = document.getElementById('mobile-menu');
|
||||
|
||||
mobileMenuButton?.addEventListener('click', () => {
|
||||
mobileMenu?.classList.toggle('hidden');
|
||||
});
|
||||
mobileMenuButton?.addEventListener('click', () => {
|
||||
mobileMenu?.classList.toggle('hidden');
|
||||
});
|
||||
|
||||
// Close menu when clicking a link
|
||||
mobileMenu?.querySelectorAll('a').forEach(link => {
|
||||
link.addEventListener('click', () => {
|
||||
mobileMenu?.classList.add('hidden');
|
||||
});
|
||||
});
|
||||
// Close menu when clicking a link
|
||||
mobileMenu?.querySelectorAll('a').forEach((link) => {
|
||||
link.addEventListener('click', () => {
|
||||
mobileMenu?.classList.add('hidden');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -2,46 +2,49 @@
|
|||
import '../styles/global.css';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
description?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
title,
|
||||
description = 'Nutriphi - Dein KI-gestützter Ernährungs-Tracker mit Mahlzeit-Foto-Analyse'
|
||||
title,
|
||||
description = 'Nutriphi - Dein KI-gestützter Ernährungs-Tracker mit Mahlzeit-Foto-Analyse',
|
||||
} = Astro.props;
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content={description} />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content={description} />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:locale" content="de_DE" />
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:locale" content="de_DE" />
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={title} />
|
||||
<meta name="twitter:description" content={description} />
|
||||
<!-- Twitter -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={title} />
|
||||
<meta name="twitter:description" content={description} />
|
||||
|
||||
<!-- Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<!-- Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
|
||||
<title>{title}</title>
|
||||
</head>
|
||||
<body class="min-h-screen bg-background-page text-text-primary antialiased">
|
||||
<slot />
|
||||
</body>
|
||||
<title>{title}</title>
|
||||
</head>
|
||||
<body class="min-h-screen bg-background-page text-text-primary antialiased">
|
||||
<slot />
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -14,274 +14,300 @@ import Card from '@manacore/shared-landing-ui/atoms/Card.astro';
|
|||
|
||||
// Feature data
|
||||
const features = [
|
||||
{
|
||||
icon: '📸',
|
||||
title: 'Foto-Analyse',
|
||||
description: 'Fotografiere deine Mahlzeit und erhalte in Sekunden detaillierte Nährwertinformationen.'
|
||||
},
|
||||
{
|
||||
icon: '🤖',
|
||||
title: 'Google Gemini KI',
|
||||
description: 'Modernste KI-Technologie erkennt Zutaten und berechnet Kalorien, Protein, Kohlenhydrate und Fett.'
|
||||
},
|
||||
{
|
||||
icon: '📊',
|
||||
title: 'Tagesbilanz',
|
||||
description: 'Behalte deine gesamte Nährwertaufnahme im Blick mit übersichtlichen Tages- und Wochenstatistiken.'
|
||||
},
|
||||
{
|
||||
icon: '🎯',
|
||||
title: 'Persönliche Ziele',
|
||||
description: 'Setze individuelle Ernährungsziele für Kalorien, Makros und Mikronährstoffe.'
|
||||
},
|
||||
{
|
||||
icon: '📱',
|
||||
title: 'Plattformübergreifend',
|
||||
description: 'Nutze Nutriphi auf iOS, Android und im Web - deine Daten sind überall synchronisiert.'
|
||||
},
|
||||
{
|
||||
icon: '💡',
|
||||
title: 'Gesundheitstipps',
|
||||
description: 'Erhalte personalisierte Empfehlungen basierend auf deinen Essgewohnheiten.'
|
||||
}
|
||||
{
|
||||
icon: '📸',
|
||||
title: 'Foto-Analyse',
|
||||
description:
|
||||
'Fotografiere deine Mahlzeit und erhalte in Sekunden detaillierte Nährwertinformationen.',
|
||||
},
|
||||
{
|
||||
icon: '🤖',
|
||||
title: 'Google Gemini KI',
|
||||
description:
|
||||
'Modernste KI-Technologie erkennt Zutaten und berechnet Kalorien, Protein, Kohlenhydrate und Fett.',
|
||||
},
|
||||
{
|
||||
icon: '📊',
|
||||
title: 'Tagesbilanz',
|
||||
description:
|
||||
'Behalte deine gesamte Nährwertaufnahme im Blick mit übersichtlichen Tages- und Wochenstatistiken.',
|
||||
},
|
||||
{
|
||||
icon: '🎯',
|
||||
title: 'Persönliche Ziele',
|
||||
description: 'Setze individuelle Ernährungsziele für Kalorien, Makros und Mikronährstoffe.',
|
||||
},
|
||||
{
|
||||
icon: '📱',
|
||||
title: 'Plattformübergreifend',
|
||||
description:
|
||||
'Nutze Nutriphi auf iOS, Android und im Web - deine Daten sind überall synchronisiert.',
|
||||
},
|
||||
{
|
||||
icon: '💡',
|
||||
title: 'Gesundheitstipps',
|
||||
description: 'Erhalte personalisierte Empfehlungen basierend auf deinen Essgewohnheiten.',
|
||||
},
|
||||
];
|
||||
|
||||
// Steps data
|
||||
const steps = [
|
||||
{
|
||||
number: '1',
|
||||
title: 'Mahlzeit fotografieren',
|
||||
description: 'Mache einfach ein Foto von deinem Essen mit deinem Smartphone - egal ob Frühstück, Mittag oder Abendessen.',
|
||||
image: '/screenshots/photo.png'
|
||||
},
|
||||
{
|
||||
number: '2',
|
||||
title: 'KI analysiert',
|
||||
description: 'Unsere Google Gemini KI erkennt automatisch alle Zutaten, schätzt Portionsgrößen und berechnet die Nährwerte.',
|
||||
image: '/screenshots/analyze.png'
|
||||
},
|
||||
{
|
||||
number: '3',
|
||||
title: 'Fortschritt verfolgen',
|
||||
description: 'Sieh deine Tagesbilanz, verfolge deinen Fortschritt und erreiche deine Gesundheitsziele.',
|
||||
image: '/screenshots/track.png'
|
||||
}
|
||||
{
|
||||
number: '1',
|
||||
title: 'Mahlzeit fotografieren',
|
||||
description:
|
||||
'Mache einfach ein Foto von deinem Essen mit deinem Smartphone - egal ob Frühstück, Mittag oder Abendessen.',
|
||||
image: '/screenshots/photo.png',
|
||||
},
|
||||
{
|
||||
number: '2',
|
||||
title: 'KI analysiert',
|
||||
description:
|
||||
'Unsere Google Gemini KI erkennt automatisch alle Zutaten, schätzt Portionsgrößen und berechnet die Nährwerte.',
|
||||
image: '/screenshots/analyze.png',
|
||||
},
|
||||
{
|
||||
number: '3',
|
||||
title: 'Fortschritt verfolgen',
|
||||
description:
|
||||
'Sieh deine Tagesbilanz, verfolge deinen Fortschritt und erreiche deine Gesundheitsziele.',
|
||||
image: '/screenshots/track.png',
|
||||
},
|
||||
];
|
||||
|
||||
// Pricing data
|
||||
const pricingPlans = [
|
||||
{
|
||||
name: 'Free',
|
||||
price: '0',
|
||||
period: '/Monat',
|
||||
description: 'Perfekt zum Ausprobieren',
|
||||
features: [
|
||||
{ text: '10 Foto-Analysen/Tag', included: true },
|
||||
{ text: 'Basis-Nährwertdaten', included: true },
|
||||
{ text: 'Tagesübersicht', included: true },
|
||||
{ text: 'Mahlzeit-Historie (7 Tage)', included: true },
|
||||
{ text: 'Unbegrenzte Analysen', included: false },
|
||||
{ text: 'Erweiterte Statistiken', included: false }
|
||||
],
|
||||
cta: {
|
||||
text: 'Kostenlos starten',
|
||||
href: '#download'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Pro',
|
||||
price: '6,99',
|
||||
period: '/Monat',
|
||||
description: 'Für ernsthafte Tracker',
|
||||
features: [
|
||||
{ text: 'Unbegrenzte Foto-Analysen', included: true },
|
||||
{ text: 'Detaillierte Mikronährstoffe', included: true },
|
||||
{ text: 'Wochen- & Monatsstatistiken', included: true },
|
||||
{ text: 'Unbegrenzte Historie', included: true },
|
||||
{ text: 'Export als CSV/PDF', included: true },
|
||||
{ text: 'Prioritäts-Analyse', included: true }
|
||||
],
|
||||
cta: {
|
||||
text: 'Pro werden',
|
||||
href: '#download'
|
||||
},
|
||||
highlighted: true,
|
||||
badge: 'Beliebt'
|
||||
},
|
||||
{
|
||||
name: 'Family',
|
||||
price: '12,99',
|
||||
period: '/Monat',
|
||||
description: 'Für die ganze Familie',
|
||||
features: [
|
||||
{ text: 'Alles aus Pro', included: true },
|
||||
{ text: 'Bis zu 5 Profile', included: true },
|
||||
{ text: 'Familien-Dashboard', included: true },
|
||||
{ text: 'Gemeinsame Mahlzeiten', included: true },
|
||||
{ text: 'Kinder-Modus', included: true },
|
||||
{ text: 'Premium-Support', included: true }
|
||||
],
|
||||
cta: {
|
||||
text: 'Family starten',
|
||||
href: '#download'
|
||||
}
|
||||
}
|
||||
{
|
||||
name: 'Free',
|
||||
price: '0',
|
||||
period: '/Monat',
|
||||
description: 'Perfekt zum Ausprobieren',
|
||||
features: [
|
||||
{ text: '10 Foto-Analysen/Tag', included: true },
|
||||
{ text: 'Basis-Nährwertdaten', included: true },
|
||||
{ text: 'Tagesübersicht', included: true },
|
||||
{ text: 'Mahlzeit-Historie (7 Tage)', included: true },
|
||||
{ text: 'Unbegrenzte Analysen', included: false },
|
||||
{ text: 'Erweiterte Statistiken', included: false },
|
||||
],
|
||||
cta: {
|
||||
text: 'Kostenlos starten',
|
||||
href: '#download',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Pro',
|
||||
price: '6,99',
|
||||
period: '/Monat',
|
||||
description: 'Für ernsthafte Tracker',
|
||||
features: [
|
||||
{ text: 'Unbegrenzte Foto-Analysen', included: true },
|
||||
{ text: 'Detaillierte Mikronährstoffe', included: true },
|
||||
{ text: 'Wochen- & Monatsstatistiken', included: true },
|
||||
{ text: 'Unbegrenzte Historie', included: true },
|
||||
{ text: 'Export als CSV/PDF', included: true },
|
||||
{ text: 'Prioritäts-Analyse', included: true },
|
||||
],
|
||||
cta: {
|
||||
text: 'Pro werden',
|
||||
href: '#download',
|
||||
},
|
||||
highlighted: true,
|
||||
badge: 'Beliebt',
|
||||
},
|
||||
{
|
||||
name: 'Family',
|
||||
price: '12,99',
|
||||
period: '/Monat',
|
||||
description: 'Für die ganze Familie',
|
||||
features: [
|
||||
{ text: 'Alles aus Pro', included: true },
|
||||
{ text: 'Bis zu 5 Profile', included: true },
|
||||
{ text: 'Familien-Dashboard', included: true },
|
||||
{ text: 'Gemeinsame Mahlzeiten', included: true },
|
||||
{ text: 'Kinder-Modus', included: true },
|
||||
{ text: 'Premium-Support', included: true },
|
||||
],
|
||||
cta: {
|
||||
text: 'Family starten',
|
||||
href: '#download',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// FAQ data
|
||||
const faqs = [
|
||||
{
|
||||
question: 'Wie genau ist die KI-Analyse?',
|
||||
answer: 'Nutriphi verwendet Google Gemini Vision, eine der fortschrittlichsten Bild-KIs. Die Genauigkeit liegt bei typischen Mahlzeiten bei etwa 85-95%. Bei komplexen oder verdeckten Zutaten kann die Genauigkeit variieren. Du kannst die Ergebnisse jederzeit manuell anpassen.'
|
||||
},
|
||||
{
|
||||
question: 'Welche Nährwerte werden analysiert?',
|
||||
answer: 'Die Analyse umfasst Kalorien, Protein, Kohlenhydrate, Fett, Ballaststoffe und Zucker. Im Pro-Plan erhältst du zusätzlich detaillierte Mikronährstoffe wie Vitamine und Mineralstoffe.'
|
||||
},
|
||||
{
|
||||
question: 'Funktioniert die App auch offline?',
|
||||
answer: 'Die Foto-Analyse benötigt eine Internetverbindung, da sie auf unseren Servern durchgeführt wird. Deine bereits analysierten Mahlzeiten und Statistiken sind jedoch offline verfügbar.'
|
||||
},
|
||||
{
|
||||
question: 'Kann ich auch Mahlzeiten manuell eingeben?',
|
||||
answer: 'Ja! Neben der Foto-Analyse kannst du Mahlzeiten auch per Text beschreiben oder aus einer Datenbank mit über 500.000 Lebensmitteln auswählen.'
|
||||
},
|
||||
{
|
||||
question: 'Wie werden meine Daten geschützt?',
|
||||
answer: 'Deine Daten werden verschlüsselt übertragen und gespeichert. Fotos werden nur für die Analyse verwendet und nicht dauerhaft gespeichert. Wir sind vollständig DSGVO-konform.'
|
||||
},
|
||||
{
|
||||
question: 'Kann ich mein Abo jederzeit kündigen?',
|
||||
answer: 'Ja, du kannst dein Pro- oder Family-Abo jederzeit kündigen. Nach der Kündigung hast du noch bis zum Ende des Abrechnungszeitraums Zugang zu allen Premium-Features.'
|
||||
}
|
||||
{
|
||||
question: 'Wie genau ist die KI-Analyse?',
|
||||
answer:
|
||||
'Nutriphi verwendet Google Gemini Vision, eine der fortschrittlichsten Bild-KIs. Die Genauigkeit liegt bei typischen Mahlzeiten bei etwa 85-95%. Bei komplexen oder verdeckten Zutaten kann die Genauigkeit variieren. Du kannst die Ergebnisse jederzeit manuell anpassen.',
|
||||
},
|
||||
{
|
||||
question: 'Welche Nährwerte werden analysiert?',
|
||||
answer:
|
||||
'Die Analyse umfasst Kalorien, Protein, Kohlenhydrate, Fett, Ballaststoffe und Zucker. Im Pro-Plan erhältst du zusätzlich detaillierte Mikronährstoffe wie Vitamine und Mineralstoffe.',
|
||||
},
|
||||
{
|
||||
question: 'Funktioniert die App auch offline?',
|
||||
answer:
|
||||
'Die Foto-Analyse benötigt eine Internetverbindung, da sie auf unseren Servern durchgeführt wird. Deine bereits analysierten Mahlzeiten und Statistiken sind jedoch offline verfügbar.',
|
||||
},
|
||||
{
|
||||
question: 'Kann ich auch Mahlzeiten manuell eingeben?',
|
||||
answer:
|
||||
'Ja! Neben der Foto-Analyse kannst du Mahlzeiten auch per Text beschreiben oder aus einer Datenbank mit über 500.000 Lebensmitteln auswählen.',
|
||||
},
|
||||
{
|
||||
question: 'Wie werden meine Daten geschützt?',
|
||||
answer:
|
||||
'Deine Daten werden verschlüsselt übertragen und gespeichert. Fotos werden nur für die Analyse verwendet und nicht dauerhaft gespeichert. Wir sind vollständig DSGVO-konform.',
|
||||
},
|
||||
{
|
||||
question: 'Kann ich mein Abo jederzeit kündigen?',
|
||||
answer:
|
||||
'Ja, du kannst dein Pro- oder Family-Abo jederzeit kündigen. Nach der Kündigung hast du noch bis zum Ende des Abrechnungszeitraums Zugang zu allen Premium-Features.',
|
||||
},
|
||||
];
|
||||
---
|
||||
|
||||
<Layout title="Nutriphi - KI-gestützter Ernährungs-Tracker">
|
||||
<Navigation />
|
||||
<Navigation />
|
||||
|
||||
<main class="pt-16">
|
||||
<HeroSection
|
||||
title="Ernährung tracken war nie einfacher"
|
||||
subtitle="Fotografiere deine Mahlzeit und erhalte sofort detaillierte Nährwertinformationen. Nutriphi nutzt Google Gemini KI für präzise Analysen - kein mühsames manuelles Eingeben mehr."
|
||||
variant="default"
|
||||
primaryCta={{
|
||||
text: 'Jetzt kostenlos starten',
|
||||
href: '#download'
|
||||
}}
|
||||
secondaryCta={{
|
||||
text: 'Features entdecken',
|
||||
href: '#features',
|
||||
variant: 'secondary'
|
||||
}}
|
||||
trustBadges={[
|
||||
{ icon: '📸', text: 'Foto-Analyse' },
|
||||
{ icon: '🔒', text: 'DSGVO-konform' },
|
||||
{ icon: '📱', text: 'iOS, Android & Web' }
|
||||
]}
|
||||
/>
|
||||
<main class="pt-16">
|
||||
<HeroSection
|
||||
title="Ernährung tracken war nie einfacher"
|
||||
subtitle="Fotografiere deine Mahlzeit und erhalte sofort detaillierte Nährwertinformationen. Nutriphi nutzt Google Gemini KI für präzise Analysen - kein mühsames manuelles Eingeben mehr."
|
||||
variant="default"
|
||||
primaryCta={{
|
||||
text: 'Jetzt kostenlos starten',
|
||||
href: '#download',
|
||||
}}
|
||||
secondaryCta={{
|
||||
text: 'Features entdecken',
|
||||
href: '#features',
|
||||
variant: 'secondary',
|
||||
}}
|
||||
trustBadges={[
|
||||
{ icon: '📸', text: 'Foto-Analyse' },
|
||||
{ icon: '🔒', text: 'DSGVO-konform' },
|
||||
{ icon: '📱', text: 'iOS, Android & Web' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<FeatureSection
|
||||
id="features"
|
||||
title="Alles für dein Ernährungstracking"
|
||||
subtitle="Nutriphi kombiniert modernste KI mit intuitivem Design für müheloses Ernährungstracking."
|
||||
features={features}
|
||||
columns={3}
|
||||
variant="cards"
|
||||
class="bg-[var(--color-background-card)]"
|
||||
>
|
||||
<!-- AI Technology Highlight -->
|
||||
<div class="mt-12 md:mt-16 px-4" slot="highlight">
|
||||
<Card variant="glow" class="max-w-4xl mx-auto" padding="lg">
|
||||
<div class="flex flex-col md:flex-row items-center gap-6 md:gap-8">
|
||||
<div class="text-5xl sm:text-6xl">🧠</div>
|
||||
<div class="flex-1 text-center md:text-left">
|
||||
<h3 class="font-bold text-xl sm:text-2xl text-[var(--color-text-primary)] mb-2 sm:mb-3">
|
||||
Powered by Google Gemini
|
||||
</h3>
|
||||
<p class="text-[var(--color-text-secondary)] text-sm sm:text-base leading-relaxed">
|
||||
Nutriphi nutzt die neueste Vision-KI von Google, um Mahlzeiten präzise zu analysieren.
|
||||
Die KI erkennt Zutaten, schätzt Portionsgrößen und berechnet Nährwerte mit hoher Genauigkeit.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-10 h-10 sm:w-12 sm:h-12 bg-[var(--color-primary)] rounded-full flex items-center justify-center">
|
||||
<span class="text-white font-bold text-sm sm:text-base">AI</span>
|
||||
</div>
|
||||
<div class="text-left">
|
||||
<div class="text-[var(--color-text-primary)] font-semibold text-sm sm:text-base">85-95%</div>
|
||||
<div class="text-[var(--color-text-secondary)] text-xs sm:text-sm">Genauigkeit</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</FeatureSection>
|
||||
<FeatureSection
|
||||
id="features"
|
||||
title="Alles für dein Ernährungstracking"
|
||||
subtitle="Nutriphi kombiniert modernste KI mit intuitivem Design für müheloses Ernährungstracking."
|
||||
features={features}
|
||||
columns={3}
|
||||
variant="cards"
|
||||
class="bg-[var(--color-background-card)]"
|
||||
>
|
||||
<!-- AI Technology Highlight -->
|
||||
<div class="mt-12 md:mt-16 px-4" slot="highlight">
|
||||
<Card variant="glow" class="max-w-4xl mx-auto" padding="lg">
|
||||
<div class="flex flex-col md:flex-row items-center gap-6 md:gap-8">
|
||||
<div class="text-5xl sm:text-6xl">🧠</div>
|
||||
<div class="flex-1 text-center md:text-left">
|
||||
<h3
|
||||
class="font-bold text-xl sm:text-2xl text-[var(--color-text-primary)] mb-2 sm:mb-3"
|
||||
>
|
||||
Powered by Google Gemini
|
||||
</h3>
|
||||
<p class="text-[var(--color-text-secondary)] text-sm sm:text-base leading-relaxed">
|
||||
Nutriphi nutzt die neueste Vision-KI von Google, um Mahlzeiten präzise zu
|
||||
analysieren. Die KI erkennt Zutaten, schätzt Portionsgrößen und berechnet Nährwerte
|
||||
mit hoher Genauigkeit.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="w-10 h-10 sm:w-12 sm:h-12 bg-[var(--color-primary)] rounded-full flex items-center justify-center"
|
||||
>
|
||||
<span class="text-white font-bold text-sm sm:text-base">AI</span>
|
||||
</div>
|
||||
<div class="text-left">
|
||||
<div class="text-[var(--color-text-primary)] font-semibold text-sm sm:text-base">
|
||||
85-95%
|
||||
</div>
|
||||
<div class="text-[var(--color-text-secondary)] text-xs sm:text-sm">Genauigkeit</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</FeatureSection>
|
||||
|
||||
<StepsSection
|
||||
id="how-it-works"
|
||||
title="In 3 Schritten zum Ziel"
|
||||
subtitle="So einfach trackst du deine Ernährung mit Nutriphi"
|
||||
steps={steps}
|
||||
showImages={false}
|
||||
alternateLayout={true}
|
||||
/>
|
||||
<StepsSection
|
||||
id="how-it-works"
|
||||
title="In 3 Schritten zum Ziel"
|
||||
subtitle="So einfach trackst du deine Ernährung mit Nutriphi"
|
||||
steps={steps}
|
||||
showImages={false}
|
||||
alternateLayout={true}
|
||||
/>
|
||||
|
||||
<PricingSection
|
||||
id="pricing"
|
||||
title="Wähle deinen Plan"
|
||||
subtitle="Starte kostenlos und upgrade, wenn du bereit bist"
|
||||
plans={pricingPlans}
|
||||
class="bg-[var(--color-background-card)]"
|
||||
/>
|
||||
<PricingSection
|
||||
id="pricing"
|
||||
title="Wähle deinen Plan"
|
||||
subtitle="Starte kostenlos und upgrade, wenn du bereit bist"
|
||||
plans={pricingPlans}
|
||||
class="bg-[var(--color-background-card)]"
|
||||
/>
|
||||
|
||||
<FAQSection
|
||||
id="faq"
|
||||
title="Häufig gestellte Fragen"
|
||||
subtitle="Alles was du über Nutriphi wissen musst"
|
||||
faqs={faqs}
|
||||
/>
|
||||
<FAQSection
|
||||
id="faq"
|
||||
title="Häufig gestellte Fragen"
|
||||
subtitle="Alles was du über Nutriphi wissen musst"
|
||||
faqs={faqs}
|
||||
/>
|
||||
|
||||
<CTASection
|
||||
id="download"
|
||||
title="Starte deine gesunde Ernährungsreise"
|
||||
subtitle="Lade Nutriphi jetzt herunter und entdecke, wie einfach Ernährungstracking sein kann. Kostenlos und ohne Kreditkarte."
|
||||
primaryCta={{ text: 'App herunterladen', href: '#' }}
|
||||
variant="highlighted"
|
||||
>
|
||||
<!-- App Store Buttons -->
|
||||
<div class="flex flex-wrap items-center justify-center gap-4 mt-8">
|
||||
<a href="#" class="inline-block hover:opacity-80 transition-opacity">
|
||||
<img src="/app-store-badge.svg" alt="Download im App Store" class="h-12" />
|
||||
</a>
|
||||
<a href="#" class="inline-block hover:opacity-80 transition-opacity">
|
||||
<img src="/google-play-badge.svg" alt="Jetzt bei Google Play" class="h-12" />
|
||||
</a>
|
||||
</div>
|
||||
<CTASection
|
||||
id="download"
|
||||
title="Starte deine gesunde Ernährungsreise"
|
||||
subtitle="Lade Nutriphi jetzt herunter und entdecke, wie einfach Ernährungstracking sein kann. Kostenlos und ohne Kreditkarte."
|
||||
primaryCta={{ text: 'App herunterladen', href: '#' }}
|
||||
variant="highlighted"
|
||||
>
|
||||
<!-- App Store Buttons -->
|
||||
<div class="flex flex-wrap items-center justify-center gap-4 mt-8">
|
||||
<a href="#" class="inline-block hover:opacity-80 transition-opacity">
|
||||
<img src="/app-store-badge.svg" alt="Download im App Store" class="h-12" />
|
||||
</a>
|
||||
<a href="#" class="inline-block hover:opacity-80 transition-opacity">
|
||||
<img src="/google-play-badge.svg" alt="Jetzt bei Google Play" class="h-12" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Trust Indicators -->
|
||||
<div class="flex flex-wrap items-center justify-center gap-4 sm:gap-6 mt-8">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-[var(--color-primary)]" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M2.166 4.999A11.954 11.954 0 0010 1.944 11.954 11.954 0 0017.834 5c.11.65.166 1.32.166 2.001 0 5.225-3.34 9.67-8 11.317C5.34 16.67 2 12.225 2 7c0-.682.057-1.35.166-2.001zm11.541 3.708a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
<span class="text-[var(--color-text-secondary)] text-sm">100% Kostenlos starten</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-[var(--color-primary)]" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
<span class="text-[var(--color-text-secondary)] text-sm">DSGVO-konform</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-[var(--color-primary)]" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M10 2a8 8 0 100 16 8 8 0 000-16zm1 11H9v-2h2v2zm0-4H9V5h2v4z"></path>
|
||||
</svg>
|
||||
<span class="text-[var(--color-text-secondary)] text-sm">Keine Kreditkarte nötig</span>
|
||||
</div>
|
||||
</div>
|
||||
</CTASection>
|
||||
</main>
|
||||
<!-- Trust Indicators -->
|
||||
<div class="flex flex-wrap items-center justify-center gap-4 sm:gap-6 mt-8">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-[var(--color-primary)]" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M2.166 4.999A11.954 11.954 0 0010 1.944 11.954 11.954 0 0017.834 5c.11.65.166 1.32.166 2.001 0 5.225-3.34 9.67-8 11.317C5.34 16.67 2 12.225 2 7c0-.682.057-1.35.166-2.001zm11.541 3.708a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||
clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
<span class="text-[var(--color-text-secondary)] text-sm">100% Kostenlos starten</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-[var(--color-primary)]" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z"
|
||||
clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
<span class="text-[var(--color-text-secondary)] text-sm">DSGVO-konform</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-[var(--color-primary)]" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M10 2a8 8 0 100 16 8 8 0 000-16zm1 11H9v-2h2v2zm0-4H9V5h2v4z"></path>
|
||||
</svg>
|
||||
<span class="text-[var(--color-text-secondary)] text-sm">Keine Kreditkarte nötig</span>
|
||||
</div>
|
||||
</div>
|
||||
</CTASection>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<Footer />
|
||||
</Layout>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
}
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,79 +1,74 @@
|
|||
{
|
||||
"expo": {
|
||||
"name": "nutriphi",
|
||||
"slug": "nutriphi",
|
||||
"version": "1.0.0",
|
||||
"scheme": "nutriphi",
|
||||
"web": {
|
||||
"bundler": "metro",
|
||||
"output": "static",
|
||||
"favicon": "./assets/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
[
|
||||
"expo-dev-launcher",
|
||||
{
|
||||
"launchMode": "most-recent"
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-camera",
|
||||
{
|
||||
"cameraPermission": "Allow Nutriphi to access your camera to take photos of your meals for nutritional analysis."
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-image-picker",
|
||||
{
|
||||
"photosPermission": "Allow Nutriphi to access your photo library to select existing meal photos."
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-location",
|
||||
{
|
||||
"locationAlwaysAndWhenInUsePermission": "Allow Nutriphi to save the location of your meals for personalized insights and restaurant detection."
|
||||
}
|
||||
]
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true,
|
||||
"tsconfigPaths": true
|
||||
},
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"splash": {
|
||||
"image": "./assets/splash.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"assetBundlePatterns": [
|
||||
"**/*"
|
||||
],
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "com.tilljs.nutriphi",
|
||||
"infoPlist": {
|
||||
"ITSAppUsesNonExemptEncryption": false
|
||||
}
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/adaptive-icon.png",
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"package": "com.tilljs.nutriphi",
|
||||
"permissions": [
|
||||
"android.permission.CAMERA",
|
||||
"android.permission.RECORD_AUDIO"
|
||||
]
|
||||
},
|
||||
"extra": {
|
||||
"router": {},
|
||||
"eas": {
|
||||
"projectId": "2099dd4c-34a0-4f8e-86d8-3ff83117711d"
|
||||
}
|
||||
}
|
||||
}
|
||||
"expo": {
|
||||
"name": "nutriphi",
|
||||
"slug": "nutriphi",
|
||||
"version": "1.0.0",
|
||||
"scheme": "nutriphi",
|
||||
"web": {
|
||||
"bundler": "metro",
|
||||
"output": "static",
|
||||
"favicon": "./assets/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
[
|
||||
"expo-dev-launcher",
|
||||
{
|
||||
"launchMode": "most-recent"
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-camera",
|
||||
{
|
||||
"cameraPermission": "Allow Nutriphi to access your camera to take photos of your meals for nutritional analysis."
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-image-picker",
|
||||
{
|
||||
"photosPermission": "Allow Nutriphi to access your photo library to select existing meal photos."
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-location",
|
||||
{
|
||||
"locationAlwaysAndWhenInUsePermission": "Allow Nutriphi to save the location of your meals for personalized insights and restaurant detection."
|
||||
}
|
||||
]
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true,
|
||||
"tsconfigPaths": true
|
||||
},
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"splash": {
|
||||
"image": "./assets/splash.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"assetBundlePatterns": ["**/*"],
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "com.tilljs.nutriphi",
|
||||
"infoPlist": {
|
||||
"ITSAppUsesNonExemptEncryption": false
|
||||
}
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/adaptive-icon.png",
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"package": "com.tilljs.nutriphi",
|
||||
"permissions": ["android.permission.CAMERA", "android.permission.RECORD_AUDIO"]
|
||||
},
|
||||
"extra": {
|
||||
"router": {},
|
||||
"eas": {
|
||||
"projectId": "2099dd4c-34a0-4f8e-86d8-3ff83117711d"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,41 +5,42 @@ import { useAppStore } from '../../store/AppStore';
|
|||
import { useTheme } from '../../hooks/useTheme';
|
||||
|
||||
export default function TabLayout() {
|
||||
const { showCameraModal, cameraMode } = useAppStore();
|
||||
const { isDark } = useTheme();
|
||||
const { showCameraModal, cameraMode } = useAppStore();
|
||||
const { isDark } = useTheme();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
tabBarActiveTintColor: '#6366f1',
|
||||
tabBarStyle: {
|
||||
backgroundColor: isDark ? '#1f2937' : 'white',
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: isDark ? '#374151' : '#e5e7eb',
|
||||
},
|
||||
}}>
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: 'Meals',
|
||||
tabBarIcon: ({ color }) => (
|
||||
<TabBarIcon sfSymbol="fork.knife" fallbackIcon="cutlery" color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="two"
|
||||
options={{
|
||||
title: 'Stats',
|
||||
tabBarIcon: ({ color }) => (
|
||||
<TabBarIcon sfSymbol="chart.bar" fallbackIcon="bar-chart" color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
return (
|
||||
<>
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
tabBarActiveTintColor: '#6366f1',
|
||||
tabBarStyle: {
|
||||
backgroundColor: isDark ? '#1f2937' : 'white',
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: isDark ? '#374151' : '#e5e7eb',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: 'Meals',
|
||||
tabBarIcon: ({ color }) => (
|
||||
<TabBarIcon sfSymbol="fork.knife" fallbackIcon="cutlery" color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="two"
|
||||
options={{
|
||||
title: 'Stats',
|
||||
tabBarIcon: ({ color }) => (
|
||||
<TabBarIcon sfSymbol="chart.bar" fallbackIcon="bar-chart" color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
|
||||
{showCameraModal && <CameraModal mode={cameraMode || 'camera'} />}
|
||||
</>
|
||||
);
|
||||
{showCameraModal && <CameraModal mode={cameraMode || 'camera'} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,34 +5,34 @@ import { ScrollViewStyleReset } from 'expo-router/html';
|
|||
// The contents of this function only run in Node.js environments and
|
||||
// do not have access to the DOM or browser APIs.
|
||||
export default function Root({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
|
||||
|
||||
{/*
|
||||
{/*
|
||||
This viewport disables scaling which makes the mobile website act more like a native app.
|
||||
However this does reduce built-in accessibility. If you want to enable scaling, use this instead:
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
*/}
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=1.00001,viewport-fit=cover"
|
||||
/>
|
||||
{/*
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=1.00001,viewport-fit=cover"
|
||||
/>
|
||||
{/*
|
||||
Disable body scrolling on web. This makes ScrollView components work closer to how they do on native.
|
||||
However, body scrolling is often nice to have for mobile web. If you want to enable it, remove this line.
|
||||
*/}
|
||||
<ScrollViewStyleReset />
|
||||
<ScrollViewStyleReset />
|
||||
|
||||
{/* Using raw CSS styles as an escape-hatch to ensure the background color never flickers in dark-mode. */}
|
||||
<style dangerouslySetInnerHTML={{ __html: responsiveBackground }} />
|
||||
{/* Add any additional <head> elements that you want globally available on web... */}
|
||||
</head>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
{/* Using raw CSS styles as an escape-hatch to ensure the background color never flickers in dark-mode. */}
|
||||
<style dangerouslySetInnerHTML={{ __html: responsiveBackground }} />
|
||||
{/* Add any additional <head> elements that you want globally available on web... */}
|
||||
</head>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
const responsiveBackground = `
|
||||
|
|
|
|||
|
|
@ -3,22 +3,22 @@ import { Link, Stack } from 'expo-router';
|
|||
import { Text, View } from 'react-native';
|
||||
|
||||
export default function NotFoundScreen() {
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ title: 'Oops!' }} />
|
||||
<View className={styles.container}>
|
||||
<Text className={styles.title}>{"This screen doesn't exist."}</Text>
|
||||
<Link href="/" className={styles.link}>
|
||||
<Text className={styles.linkText}>Go to home screen!</Text>
|
||||
</Link>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ title: 'Oops!' }} />
|
||||
<View className={styles.container}>
|
||||
<Text className={styles.title}>{"This screen doesn't exist."}</Text>
|
||||
<Link href="/" className={styles.link}>
|
||||
<Text className={styles.linkText}>Go to home screen!</Text>
|
||||
</Link>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = {
|
||||
container: `items-center flex-1 justify-center p-5`,
|
||||
title: `text-xl font-bold`,
|
||||
link: `mt-4 pt-4`,
|
||||
linkText: `text-base text-[#2e78b7]`,
|
||||
container: `items-center flex-1 justify-center p-5`,
|
||||
title: `text-xl font-bold`,
|
||||
link: `mt-4 pt-4`,
|
||||
linkText: `text-base text-[#2e78b7]`,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -8,56 +8,56 @@ import { useEffect } from 'react';
|
|||
import { PhotoService } from '../services/storage/PhotoService';
|
||||
|
||||
export const unstable_settings = {
|
||||
// Ensure that reloading on `/modal` keeps a back button present.
|
||||
initialRouteName: 'index',
|
||||
// Ensure that reloading on `/modal` keeps a back button present.
|
||||
initialRouteName: 'index',
|
||||
};
|
||||
|
||||
export default function RootLayout() {
|
||||
const { isReady, error } = useDatabase();
|
||||
const { isReady, error } = useDatabase();
|
||||
|
||||
// Initialize theme on app start
|
||||
useTheme();
|
||||
// Initialize theme on app start
|
||||
useTheme();
|
||||
|
||||
// Clean up temporary photos when app comes to foreground
|
||||
useEffect(() => {
|
||||
const handleAppStateChange = async (nextAppState: string) => {
|
||||
if (nextAppState === 'active') {
|
||||
try {
|
||||
const photoService = PhotoService.getInstance();
|
||||
await photoService.cleanupTempPhotos();
|
||||
console.log('Temporary photos cleaned up on app foreground');
|
||||
} catch (error) {
|
||||
console.warn('Failed to cleanup temp photos on foreground:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
// Clean up temporary photos when app comes to foreground
|
||||
useEffect(() => {
|
||||
const handleAppStateChange = async (nextAppState: string) => {
|
||||
if (nextAppState === 'active') {
|
||||
try {
|
||||
const photoService = PhotoService.getInstance();
|
||||
await photoService.cleanupTempPhotos();
|
||||
console.log('Temporary photos cleaned up on app foreground');
|
||||
} catch (error) {
|
||||
console.warn('Failed to cleanup temp photos on foreground:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const subscription = AppState.addEventListener('change', handleAppStateChange);
|
||||
return () => subscription?.remove();
|
||||
}, []);
|
||||
const subscription = AppState.addEventListener('change', handleAppStateChange);
|
||||
return () => subscription?.remove();
|
||||
}, []);
|
||||
|
||||
if (!isReady) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center bg-white">
|
||||
{error ? (
|
||||
<View className="items-center space-y-4">
|
||||
<Text className="text-lg font-semibold text-red-500">Database Error</Text>
|
||||
<Text className="px-4 text-center text-gray-600">{error}</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View className="items-center space-y-4">
|
||||
<ActivityIndicator size="large" color="#6366f1" />
|
||||
<Text className="text-gray-600">Initializing Nutriphi...</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (!isReady) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center bg-white">
|
||||
{error ? (
|
||||
<View className="items-center space-y-4">
|
||||
<Text className="text-lg font-semibold text-red-500">Database Error</Text>
|
||||
<Text className="px-4 text-center text-gray-600">{error}</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View className="items-center space-y-4">
|
||||
<ActivityIndicator size="large" color="#6366f1" />
|
||||
<Text className="text-gray-600">Initializing Nutriphi...</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Stack.Screen name="index" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="modal" options={{ presentation: 'modal' }} />
|
||||
</Stack>
|
||||
);
|
||||
return (
|
||||
<Stack>
|
||||
<Stack.Screen name="index" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="modal" options={{ presentation: 'modal' }} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,45 +8,45 @@ import { MealWithItems } from '../types/Database';
|
|||
import { useAppStore } from '../store/AppStore';
|
||||
|
||||
export default function Home() {
|
||||
const { toggleCameraModal, showCameraModal, cameraMode } = useAppStore();
|
||||
const { toggleCameraModal, showCameraModal, cameraMode } = useAppStore();
|
||||
|
||||
const handleMealPress = (meal: MealWithItems) => {
|
||||
router.push(`/meal/${meal.id}`);
|
||||
};
|
||||
const handleMealPress = (meal: MealWithItems) => {
|
||||
router.push(`/meal/${meal.id}`);
|
||||
};
|
||||
|
||||
const handleCameraPress = () => {
|
||||
toggleCameraModal(true, 'camera');
|
||||
};
|
||||
const handleCameraPress = () => {
|
||||
toggleCameraModal(true, 'camera');
|
||||
};
|
||||
|
||||
const handleGalleryPress = () => {
|
||||
toggleCameraModal(true, 'gallery');
|
||||
};
|
||||
const handleGalleryPress = () => {
|
||||
toggleCameraModal(true, 'gallery');
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SafeAreaView className="flex-1 bg-gray-50 dark:bg-gray-900">
|
||||
<MealList onMealPress={handleMealPress} />
|
||||
return (
|
||||
<>
|
||||
<SafeAreaView className="flex-1 bg-gray-50 dark:bg-gray-900">
|
||||
<MealList onMealPress={handleMealPress} />
|
||||
|
||||
{/* Camera Button (larger, centered) */}
|
||||
<FloatingActionButton
|
||||
onPress={handleCameraPress}
|
||||
sfSymbol="camera"
|
||||
fallbackIcon="camera"
|
||||
size="large"
|
||||
position="center"
|
||||
/>
|
||||
{/* Camera Button (larger, centered) */}
|
||||
<FloatingActionButton
|
||||
onPress={handleCameraPress}
|
||||
sfSymbol="camera"
|
||||
fallbackIcon="camera"
|
||||
size="large"
|
||||
position="center"
|
||||
/>
|
||||
|
||||
{/* Gallery Button (smaller, right) */}
|
||||
<FloatingActionButton
|
||||
onPress={handleGalleryPress}
|
||||
sfSymbol="photo"
|
||||
fallbackIcon="image"
|
||||
size="normal"
|
||||
position="right"
|
||||
/>
|
||||
</SafeAreaView>
|
||||
{/* Gallery Button (smaller, right) */}
|
||||
<FloatingActionButton
|
||||
onPress={handleGalleryPress}
|
||||
sfSymbol="photo"
|
||||
fallbackIcon="image"
|
||||
size="normal"
|
||||
position="right"
|
||||
/>
|
||||
</SafeAreaView>
|
||||
|
||||
{showCameraModal && <CameraModal mode={cameraMode || 'camera'} />}
|
||||
</>
|
||||
);
|
||||
{showCameraModal && <CameraModal mode={cameraMode || 'camera'} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,245 +9,246 @@ import { FoodItemList } from '@/components/meals/FoodItemList';
|
|||
import { AnalysisStatusIndicator } from '@/components/meals/AnalysisStatusIndicator';
|
||||
|
||||
export default function MealDetailScreen() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const { selectedMeal, loadMealById, isLoading } = useMealStore();
|
||||
const [imageError, setImageError] = useState(false);
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const { selectedMeal, loadMealById, isLoading } = useMealStore();
|
||||
const [imageError, setImageError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
loadMealById(parseInt(id));
|
||||
setImageError(false); // Reset image error state when loading new meal
|
||||
}
|
||||
}, [id, loadMealById]);
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
loadMealById(parseInt(id));
|
||||
setImageError(false); // Reset image error state when loading new meal
|
||||
}
|
||||
}, [id, loadMealById]);
|
||||
|
||||
// Poll for updates if analysis is pending
|
||||
useEffect(() => {
|
||||
if (!selectedMeal || selectedMeal.analysis_status !== 'pending') {
|
||||
return;
|
||||
}
|
||||
// Poll for updates if analysis is pending
|
||||
useEffect(() => {
|
||||
if (!selectedMeal || selectedMeal.analysis_status !== 'pending') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Poll every 2 seconds
|
||||
const interval = setInterval(() => {
|
||||
loadMealById(selectedMeal.id);
|
||||
}, 2000);
|
||||
// Poll every 2 seconds
|
||||
const interval = setInterval(() => {
|
||||
loadMealById(selectedMeal.id);
|
||||
}, 2000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [selectedMeal?.id, selectedMeal?.analysis_status, loadMealById]);
|
||||
return () => clearInterval(interval);
|
||||
}, [selectedMeal?.id, selectedMeal?.analysis_status, loadMealById]);
|
||||
|
||||
// Add debug logging when component renders
|
||||
useEffect(() => {
|
||||
console.log(
|
||||
'Meal detail component rendered with selectedMeal:',
|
||||
selectedMeal?.id,
|
||||
'photo_path:',
|
||||
selectedMeal?.photo_path
|
||||
);
|
||||
}, [selectedMeal]);
|
||||
// Add debug logging when component renders
|
||||
useEffect(() => {
|
||||
console.log(
|
||||
'Meal detail component rendered with selectedMeal:',
|
||||
selectedMeal?.id,
|
||||
'photo_path:',
|
||||
selectedMeal?.photo_path
|
||||
);
|
||||
}, [selectedMeal]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center bg-white">
|
||||
<LoadingSpinner />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center bg-white">
|
||||
<LoadingSpinner />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!selectedMeal) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center bg-white">
|
||||
<Text className="text-lg text-gray-500">Mahlzeit nicht gefunden</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (!selectedMeal) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center bg-white">
|
||||
<Text className="text-lg text-gray-500">Mahlzeit nicht gefunden</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const generateMealTitle = (meal: any): string => {
|
||||
if (meal.food_items && meal.food_items.length > 0) {
|
||||
const foodNames = meal.food_items.map((item: any) => item.name);
|
||||
const generateMealTitle = (meal: any): string => {
|
||||
if (meal.food_items && meal.food_items.length > 0) {
|
||||
const foodNames = meal.food_items.map((item: any) => item.name);
|
||||
|
||||
if (foodNames.length === 1) {
|
||||
return foodNames[0];
|
||||
} else if (foodNames.length === 2) {
|
||||
return `${foodNames[0]} & ${foodNames[1]}`;
|
||||
} else if (foodNames.length > 2) {
|
||||
return `${foodNames[0]} & ${foodNames.length - 1} weitere`;
|
||||
}
|
||||
}
|
||||
if (foodNames.length === 1) {
|
||||
return foodNames[0];
|
||||
} else if (foodNames.length === 2) {
|
||||
return `${foodNames[0]} & ${foodNames[1]}`;
|
||||
} else if (foodNames.length > 2) {
|
||||
return `${foodNames[0]} & ${foodNames.length - 1} weitere`;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to meal type if no food items
|
||||
return getMealTypeLabel(meal.meal_type);
|
||||
};
|
||||
// Fallback to meal type if no food items
|
||||
return getMealTypeLabel(meal.meal_type);
|
||||
};
|
||||
|
||||
const formatDate = (timestamp: string) => {
|
||||
return new Date(timestamp).toLocaleDateString('de-DE', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
const formatDate = (timestamp: string) => {
|
||||
return new Date(timestamp).toLocaleDateString('de-DE', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
const getMealTypeIcon = (mealType?: string) => {
|
||||
switch (mealType) {
|
||||
case 'breakfast':
|
||||
return 'sunny-outline';
|
||||
case 'lunch':
|
||||
return 'restaurant-outline';
|
||||
case 'dinner':
|
||||
return 'moon-outline';
|
||||
case 'snack':
|
||||
return 'cafe-outline';
|
||||
default:
|
||||
return 'restaurant-outline';
|
||||
}
|
||||
};
|
||||
const getMealTypeIcon = (mealType?: string) => {
|
||||
switch (mealType) {
|
||||
case 'breakfast':
|
||||
return 'sunny-outline';
|
||||
case 'lunch':
|
||||
return 'restaurant-outline';
|
||||
case 'dinner':
|
||||
return 'moon-outline';
|
||||
case 'snack':
|
||||
return 'cafe-outline';
|
||||
default:
|
||||
return 'restaurant-outline';
|
||||
}
|
||||
};
|
||||
|
||||
const getMealTypeLabel = (mealType?: string) => {
|
||||
switch (mealType) {
|
||||
case 'breakfast':
|
||||
return 'Frühstück';
|
||||
case 'lunch':
|
||||
return 'Mittagessen';
|
||||
case 'dinner':
|
||||
return 'Abendessen';
|
||||
case 'snack':
|
||||
return 'Snack';
|
||||
default:
|
||||
return 'Mahlzeit';
|
||||
}
|
||||
};
|
||||
const getMealTypeLabel = (mealType?: string) => {
|
||||
switch (mealType) {
|
||||
case 'breakfast':
|
||||
return 'Frühstück';
|
||||
case 'lunch':
|
||||
return 'Mittagessen';
|
||||
case 'dinner':
|
||||
return 'Abendessen';
|
||||
case 'snack':
|
||||
return 'Snack';
|
||||
default:
|
||||
return 'Mahlzeit';
|
||||
}
|
||||
};
|
||||
|
||||
const renderStars = (rating?: number) => {
|
||||
if (!rating) return null;
|
||||
const renderStars = (rating?: number) => {
|
||||
if (!rating) return null;
|
||||
|
||||
return (
|
||||
<View className="flex-row">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<Ionicons
|
||||
key={star}
|
||||
name={star <= rating ? 'star' : 'star-outline'}
|
||||
size={20}
|
||||
color={star <= rating ? '#fbbf24' : '#d1d5db'}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<View className="flex-row">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<Ionicons
|
||||
key={star}
|
||||
name={star <= rating ? 'star' : 'star-outline'}
|
||||
size={20}
|
||||
color={star <= rating ? '#fbbf24' : '#d1d5db'}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollView className="flex-1 bg-white">
|
||||
{/* Header */}
|
||||
<View className="relative">
|
||||
<TouchableOpacity
|
||||
onPress={() => router.back()}
|
||||
className="absolute left-4 top-12 z-10 rounded-full bg-black/50 p-2">
|
||||
<Ionicons name="arrow-back" size={24} color="white" />
|
||||
</TouchableOpacity>
|
||||
return (
|
||||
<ScrollView className="flex-1 bg-white">
|
||||
{/* Header */}
|
||||
<View className="relative">
|
||||
<TouchableOpacity
|
||||
onPress={() => router.back()}
|
||||
className="absolute left-4 top-12 z-10 rounded-full bg-black/50 p-2"
|
||||
>
|
||||
<Ionicons name="arrow-back" size={24} color="white" />
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Photo */}
|
||||
<View className="h-80 bg-gray-200">
|
||||
{selectedMeal.photo_path && !imageError ? (
|
||||
<Image
|
||||
source={{ uri: selectedMeal.photo_path }}
|
||||
className="h-full w-full"
|
||||
resizeMode="cover"
|
||||
onError={(error) => {
|
||||
console.error('Detail page image loading error:', error);
|
||||
console.log('Detail page photo_path:', selectedMeal.photo_path);
|
||||
setImageError(true);
|
||||
}}
|
||||
onLoad={() => {
|
||||
console.log('Detail page image loaded successfully:', selectedMeal.photo_path);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<Ionicons name={getMealTypeIcon(selectedMeal.meal_type)} size={64} color="#9ca3af" />
|
||||
<Text className="mt-2 text-sm text-gray-500">
|
||||
{imageError ? 'Foto konnte nicht geladen werden' : 'Kein Foto verfügbar'}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
{/* Photo */}
|
||||
<View className="h-80 bg-gray-200">
|
||||
{selectedMeal.photo_path && !imageError ? (
|
||||
<Image
|
||||
source={{ uri: selectedMeal.photo_path }}
|
||||
className="h-full w-full"
|
||||
resizeMode="cover"
|
||||
onError={(error) => {
|
||||
console.error('Detail page image loading error:', error);
|
||||
console.log('Detail page photo_path:', selectedMeal.photo_path);
|
||||
setImageError(true);
|
||||
}}
|
||||
onLoad={() => {
|
||||
console.log('Detail page image loaded successfully:', selectedMeal.photo_path);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<Ionicons name={getMealTypeIcon(selectedMeal.meal_type)} size={64} color="#9ca3af" />
|
||||
<Text className="mt-2 text-sm text-gray-500">
|
||||
{imageError ? 'Foto konnte nicht geladen werden' : 'Kein Foto verfügbar'}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Content */}
|
||||
<View className="p-4">
|
||||
{/* Meal Title and Rating */}
|
||||
<View className="mb-2 flex-row items-start justify-between">
|
||||
<View className="flex-1">
|
||||
<Text className="text-2xl font-bold text-gray-900" numberOfLines={2}>
|
||||
{generateMealTitle(selectedMeal)}
|
||||
</Text>
|
||||
</View>
|
||||
{selectedMeal.user_rating && (
|
||||
<View className="ml-4">{renderStars(selectedMeal.user_rating)}</View>
|
||||
)}
|
||||
</View>
|
||||
{/* Content */}
|
||||
<View className="p-4">
|
||||
{/* Meal Title and Rating */}
|
||||
<View className="mb-2 flex-row items-start justify-between">
|
||||
<View className="flex-1">
|
||||
<Text className="text-2xl font-bold text-gray-900" numberOfLines={2}>
|
||||
{generateMealTitle(selectedMeal)}
|
||||
</Text>
|
||||
</View>
|
||||
{selectedMeal.user_rating && (
|
||||
<View className="ml-4">{renderStars(selectedMeal.user_rating)}</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Meal Type and Date */}
|
||||
<View className="mb-6 flex-row items-center">
|
||||
<Ionicons
|
||||
name={getMealTypeIcon(selectedMeal.meal_type)}
|
||||
size={20}
|
||||
color="#6b7280"
|
||||
style={{ marginRight: 6 }}
|
||||
/>
|
||||
<Text className="text-base text-gray-600">
|
||||
{getMealTypeLabel(selectedMeal.meal_type)}
|
||||
</Text>
|
||||
<Text className="mx-2 text-gray-400">•</Text>
|
||||
<Text className="text-base text-gray-600">{formatDate(selectedMeal.timestamp)}</Text>
|
||||
</View>
|
||||
{/* Meal Type and Date */}
|
||||
<View className="mb-6 flex-row items-center">
|
||||
<Ionicons
|
||||
name={getMealTypeIcon(selectedMeal.meal_type)}
|
||||
size={20}
|
||||
color="#6b7280"
|
||||
style={{ marginRight: 6 }}
|
||||
/>
|
||||
<Text className="text-base text-gray-600">
|
||||
{getMealTypeLabel(selectedMeal.meal_type)}
|
||||
</Text>
|
||||
<Text className="mx-2 text-gray-400">•</Text>
|
||||
<Text className="text-base text-gray-600">{formatDate(selectedMeal.timestamp)}</Text>
|
||||
</View>
|
||||
|
||||
{/* Location */}
|
||||
{selectedMeal.location && (
|
||||
<View className="mb-6 flex-row items-center">
|
||||
<Ionicons name="location-outline" size={20} color="#6b7280" />
|
||||
<Text className="ml-2 text-gray-600">{selectedMeal.location}</Text>
|
||||
</View>
|
||||
)}
|
||||
{/* Location */}
|
||||
{selectedMeal.location && (
|
||||
<View className="mb-6 flex-row items-center">
|
||||
<Ionicons name="location-outline" size={20} color="#6b7280" />
|
||||
<Text className="ml-2 text-gray-600">{selectedMeal.location}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Nutrition Overview */}
|
||||
{selectedMeal.analysis_status === 'completed' && (
|
||||
<View className="mb-6">
|
||||
<Text className="mb-3 text-lg font-semibold text-gray-900">Nährwerte</Text>
|
||||
<NutritionBar meal={selectedMeal} showDetailed={true} />
|
||||
</View>
|
||||
)}
|
||||
{/* Nutrition Overview */}
|
||||
{selectedMeal.analysis_status === 'completed' && (
|
||||
<View className="mb-6">
|
||||
<Text className="mb-3 text-lg font-semibold text-gray-900">Nährwerte</Text>
|
||||
<NutritionBar meal={selectedMeal} showDetailed={true} />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Analysis Status */}
|
||||
<View className="mb-6">
|
||||
<AnalysisStatusIndicator status={selectedMeal.analysis_status} />
|
||||
</View>
|
||||
{/* Analysis Status */}
|
||||
<View className="mb-6">
|
||||
<AnalysisStatusIndicator status={selectedMeal.analysis_status} />
|
||||
</View>
|
||||
|
||||
{/* Food Items */}
|
||||
{selectedMeal.food_items && selectedMeal.food_items.length > 0 && (
|
||||
<View className="mb-6">
|
||||
<FoodItemList foodItems={selectedMeal.food_items} />
|
||||
</View>
|
||||
)}
|
||||
{/* Food Items */}
|
||||
{selectedMeal.food_items && selectedMeal.food_items.length > 0 && (
|
||||
<View className="mb-6">
|
||||
<FoodItemList foodItems={selectedMeal.food_items} />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* User Notes */}
|
||||
{selectedMeal.user_notes && (
|
||||
<View className="mb-6">
|
||||
<Text className="mb-3 text-lg font-semibold text-gray-900">Notizen</Text>
|
||||
<View className="rounded-lg bg-blue-50 p-3">
|
||||
<Text className="text-gray-700">{selectedMeal.user_notes}</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
{/* User Notes */}
|
||||
{selectedMeal.user_notes && (
|
||||
<View className="mb-6">
|
||||
<Text className="mb-3 text-lg font-semibold text-gray-900">Notizen</Text>
|
||||
<View className="rounded-lg bg-blue-50 p-3">
|
||||
<Text className="text-gray-700">{selectedMeal.user_notes}</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Analysis Confidence */}
|
||||
{selectedMeal.analysis_confidence && (
|
||||
<View className="mb-6">
|
||||
<Text className="text-sm text-gray-600">
|
||||
Analyse-Sicherheit: {Math.round(selectedMeal.analysis_confidence * 100)}%
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
{/* Analysis Confidence */}
|
||||
{selectedMeal.analysis_confidence && (
|
||||
<View className="mb-6">
|
||||
<Text className="text-sm text-gray-600">
|
||||
Analyse-Sicherheit: {Math.round(selectedMeal.analysis_confidence * 100)}%
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,10 +4,10 @@ import { Platform } from 'react-native';
|
|||
import { ScreenContent } from '~/components/ScreenContent';
|
||||
|
||||
export default function Modal() {
|
||||
return (
|
||||
<>
|
||||
<ScreenContent path="app/modal.tsx" title="Modal"></ScreenContent>
|
||||
<StatusBar style={Platform.OS === 'ios' ? 'light' : 'auto'} />
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<ScreenContent path="app/modal.tsx" title="Modal"></ScreenContent>
|
||||
<StatusBar style={Platform.OS === 'ios' ? 'light' : 'auto'} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,288 +9,294 @@ import { UserPreferencesService } from '../services/UserPreferencesService';
|
|||
import LoadingOverlay from '../components/ui/LoadingOverlay';
|
||||
|
||||
export default function Settings() {
|
||||
const { theme, updateTheme } = useTheme();
|
||||
const [isClearing, setIsClearing] = useState(false);
|
||||
const [locationEnabled, setLocationEnabled] = useState(true);
|
||||
const [isLoadingPrefs, setIsLoadingPrefs] = useState(true);
|
||||
const { theme, updateTheme } = useTheme();
|
||||
const [isClearing, setIsClearing] = useState(false);
|
||||
const [locationEnabled, setLocationEnabled] = useState(true);
|
||||
const [isLoadingPrefs, setIsLoadingPrefs] = useState(true);
|
||||
|
||||
const themeOptions = [
|
||||
{ value: 'light', label: 'Light', icon: '☀️' },
|
||||
{ value: 'dark', label: 'Dark', icon: '🌙' },
|
||||
{ value: 'system', label: 'System', icon: '📱' },
|
||||
];
|
||||
const themeOptions = [
|
||||
{ value: 'light', label: 'Light', icon: '☀️' },
|
||||
{ value: 'dark', label: 'Dark', icon: '🌙' },
|
||||
{ value: 'system', label: 'System', icon: '📱' },
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
loadPreferences();
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
loadPreferences();
|
||||
}, []);
|
||||
|
||||
const loadPreferences = async () => {
|
||||
try {
|
||||
const prefsService = UserPreferencesService.getInstance();
|
||||
const prefs = await prefsService.getPreferences();
|
||||
setLocationEnabled(prefs.locationEnabled);
|
||||
} catch (error) {
|
||||
console.error('Failed to load preferences:', error);
|
||||
} finally {
|
||||
setIsLoadingPrefs(false);
|
||||
}
|
||||
};
|
||||
const loadPreferences = async () => {
|
||||
try {
|
||||
const prefsService = UserPreferencesService.getInstance();
|
||||
const prefs = await prefsService.getPreferences();
|
||||
setLocationEnabled(prefs.locationEnabled);
|
||||
} catch (error) {
|
||||
console.error('Failed to load preferences:', error);
|
||||
} finally {
|
||||
setIsLoadingPrefs(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleThemeSelect = (selectedTheme: 'light' | 'dark' | 'system') => {
|
||||
updateTheme(selectedTheme);
|
||||
};
|
||||
const handleThemeSelect = (selectedTheme: 'light' | 'dark' | 'system') => {
|
||||
updateTheme(selectedTheme);
|
||||
};
|
||||
|
||||
const handleLocationToggle = async (value: boolean) => {
|
||||
setLocationEnabled(value);
|
||||
try {
|
||||
const prefsService = UserPreferencesService.getInstance();
|
||||
await prefsService.setLocationEnabled(value);
|
||||
} catch (error) {
|
||||
console.error('Failed to update location preference:', error);
|
||||
// Revert on error
|
||||
setLocationEnabled(!value);
|
||||
Alert.alert('Fehler', 'Einstellung konnte nicht gespeichert werden.');
|
||||
}
|
||||
};
|
||||
const handleLocationToggle = async (value: boolean) => {
|
||||
setLocationEnabled(value);
|
||||
try {
|
||||
const prefsService = UserPreferencesService.getInstance();
|
||||
await prefsService.setLocationEnabled(value);
|
||||
} catch (error) {
|
||||
console.error('Failed to update location preference:', error);
|
||||
// Revert on error
|
||||
setLocationEnabled(!value);
|
||||
Alert.alert('Fehler', 'Einstellung konnte nicht gespeichert werden.');
|
||||
}
|
||||
};
|
||||
|
||||
const openAppSettings = () => {
|
||||
Linking.openSettings();
|
||||
};
|
||||
const openAppSettings = () => {
|
||||
Linking.openSettings();
|
||||
};
|
||||
|
||||
const handleDeleteAllData = () => {
|
||||
Alert.alert(
|
||||
'Alle Daten löschen',
|
||||
'Diese Aktion kann NICHT rückgängig gemacht werden. Alle Mahlzeiten, Fotos und persönlichen Daten werden dauerhaft gelöscht.\n\nMöchten Sie wirklich fortfahren?',
|
||||
[
|
||||
{
|
||||
text: 'Abbrechen',
|
||||
style: 'cancel',
|
||||
},
|
||||
{
|
||||
text: 'Alles löschen',
|
||||
style: 'destructive',
|
||||
onPress: confirmDeleteAllData,
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
const handleDeleteAllData = () => {
|
||||
Alert.alert(
|
||||
'Alle Daten löschen',
|
||||
'Diese Aktion kann NICHT rückgängig gemacht werden. Alle Mahlzeiten, Fotos und persönlichen Daten werden dauerhaft gelöscht.\n\nMöchten Sie wirklich fortfahren?',
|
||||
[
|
||||
{
|
||||
text: 'Abbrechen',
|
||||
style: 'cancel',
|
||||
},
|
||||
{
|
||||
text: 'Alles löschen',
|
||||
style: 'destructive',
|
||||
onPress: confirmDeleteAllData,
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
const confirmDeleteAllData = async () => {
|
||||
setIsClearing(true);
|
||||
const confirmDeleteAllData = async () => {
|
||||
setIsClearing(true);
|
||||
|
||||
try {
|
||||
const dataClearingService = DataClearingService.getInstance();
|
||||
const result = await dataClearingService.clearAllData();
|
||||
try {
|
||||
const dataClearingService = DataClearingService.getInstance();
|
||||
const result = await dataClearingService.clearAllData();
|
||||
|
||||
if (result.success) {
|
||||
Alert.alert('Erfolgreich', 'Alle Daten wurden gelöscht.', [
|
||||
{
|
||||
text: 'OK',
|
||||
onPress: () => router.replace('/(tabs)'),
|
||||
},
|
||||
]);
|
||||
} else {
|
||||
Alert.alert(
|
||||
'Teilweise erfolgreich',
|
||||
`Einige Daten konnten nicht gelöscht werden:\n\n${result.errors.join('\n')}`,
|
||||
[{ text: 'OK' }]
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
Alert.alert('Fehler', `Beim Löschen der Daten ist ein Fehler aufgetreten: ${error}`, [
|
||||
{ text: 'OK' },
|
||||
]);
|
||||
} finally {
|
||||
setIsClearing(false);
|
||||
}
|
||||
};
|
||||
if (result.success) {
|
||||
Alert.alert('Erfolgreich', 'Alle Daten wurden gelöscht.', [
|
||||
{
|
||||
text: 'OK',
|
||||
onPress: () => router.replace('/(tabs)'),
|
||||
},
|
||||
]);
|
||||
} else {
|
||||
Alert.alert(
|
||||
'Teilweise erfolgreich',
|
||||
`Einige Daten konnten nicht gelöscht werden:\n\n${result.errors.join('\n')}`,
|
||||
[{ text: 'OK' }]
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
Alert.alert('Fehler', `Beim Löschen der Daten ist ein Fehler aufgetreten: ${error}`, [
|
||||
{ text: 'OK' },
|
||||
]);
|
||||
} finally {
|
||||
setIsClearing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: 'Settings',
|
||||
headerShown: true,
|
||||
headerLeft: () => (
|
||||
<TouchableOpacity onPress={() => router.back()} className="p-2">
|
||||
<Text className="text-lg">←</Text>
|
||||
</TouchableOpacity>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: 'Settings',
|
||||
headerShown: true,
|
||||
headerLeft: () => (
|
||||
<TouchableOpacity onPress={() => router.back()} className="p-2">
|
||||
<Text className="text-lg">←</Text>
|
||||
</TouchableOpacity>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
<SafeAreaView className="flex-1 bg-gray-50 dark:bg-gray-900">
|
||||
<ScrollView className="flex-1">
|
||||
{/* App Info Section */}
|
||||
<View className="mx-4 mt-4 rounded-lg bg-white p-4 shadow-sm dark:bg-gray-800">
|
||||
<Text className="mb-3 text-lg font-semibold text-gray-900 dark:text-white">
|
||||
App Info
|
||||
</Text>
|
||||
<SafeAreaView className="flex-1 bg-gray-50 dark:bg-gray-900">
|
||||
<ScrollView className="flex-1">
|
||||
{/* App Info Section */}
|
||||
<View className="mx-4 mt-4 rounded-lg bg-white p-4 shadow-sm dark:bg-gray-800">
|
||||
<Text className="mb-3 text-lg font-semibold text-gray-900 dark:text-white">
|
||||
App Info
|
||||
</Text>
|
||||
|
||||
<View className="space-y-3">
|
||||
<View className="flex-row items-center justify-between">
|
||||
<Text className="text-gray-600 dark:text-gray-300">App Name</Text>
|
||||
<Text className="font-medium text-gray-900 dark:text-white">NutriPhi</Text>
|
||||
</View>
|
||||
<View className="space-y-3">
|
||||
<View className="flex-row items-center justify-between">
|
||||
<Text className="text-gray-600 dark:text-gray-300">App Name</Text>
|
||||
<Text className="font-medium text-gray-900 dark:text-white">NutriPhi</Text>
|
||||
</View>
|
||||
|
||||
<View className="flex-row items-center justify-between">
|
||||
<Text className="text-gray-600 dark:text-gray-300">Version</Text>
|
||||
<Text className="font-medium text-gray-900 dark:text-white">1.0.0</Text>
|
||||
</View>
|
||||
<View className="flex-row items-center justify-between">
|
||||
<Text className="text-gray-600 dark:text-gray-300">Version</Text>
|
||||
<Text className="font-medium text-gray-900 dark:text-white">1.0.0</Text>
|
||||
</View>
|
||||
|
||||
<View className="flex-row items-center justify-between">
|
||||
<Text className="text-gray-600 dark:text-gray-300">Build</Text>
|
||||
<Text className="font-medium text-gray-900 dark:text-white">1</Text>
|
||||
</View>
|
||||
<View className="flex-row items-center justify-between">
|
||||
<Text className="text-gray-600 dark:text-gray-300">Build</Text>
|
||||
<Text className="font-medium text-gray-900 dark:text-white">1</Text>
|
||||
</View>
|
||||
|
||||
<View className="mt-3 border-t border-gray-200 pt-3 dark:border-gray-600">
|
||||
<Text className="text-sm text-gray-600 dark:text-gray-300">
|
||||
Track your nutrition with AI-powered meal analysis
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<View className="mt-3 border-t border-gray-200 pt-3 dark:border-gray-600">
|
||||
<Text className="text-sm text-gray-600 dark:text-gray-300">
|
||||
Track your nutrition with AI-powered meal analysis
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Theme Section */}
|
||||
<View className="mx-4 mt-4 rounded-lg bg-white p-4 shadow-sm dark:bg-gray-800">
|
||||
<Text className="mb-3 text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Appearance
|
||||
</Text>
|
||||
{/* Theme Section */}
|
||||
<View className="mx-4 mt-4 rounded-lg bg-white p-4 shadow-sm dark:bg-gray-800">
|
||||
<Text className="mb-3 text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Appearance
|
||||
</Text>
|
||||
|
||||
<Text className="mb-3 text-gray-600 dark:text-gray-300">Theme</Text>
|
||||
<Text className="mb-3 text-gray-600 dark:text-gray-300">Theme</Text>
|
||||
|
||||
<View className="space-y-2">
|
||||
{themeOptions.map((option) => (
|
||||
<TouchableOpacity
|
||||
key={option.value}
|
||||
onPress={() => handleThemeSelect(option.value as 'light' | 'dark' | 'system')}
|
||||
className={`flex-row items-center justify-between rounded-lg border p-3 ${
|
||||
theme === option.value
|
||||
? 'border-indigo-500 bg-indigo-50 dark:border-indigo-400 dark:bg-indigo-900/30'
|
||||
: 'border-gray-200 bg-gray-50 dark:border-gray-600 dark:bg-gray-700'
|
||||
}`}>
|
||||
<View className="flex-row items-center">
|
||||
<Text className="mr-3 text-lg">{option.icon}</Text>
|
||||
<Text
|
||||
className={`font-medium ${
|
||||
theme === option.value
|
||||
? 'text-indigo-700 dark:text-indigo-300'
|
||||
: 'text-gray-900 dark:text-white'
|
||||
}`}>
|
||||
{option.label}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="space-y-2">
|
||||
{themeOptions.map((option) => (
|
||||
<TouchableOpacity
|
||||
key={option.value}
|
||||
onPress={() => handleThemeSelect(option.value as 'light' | 'dark' | 'system')}
|
||||
className={`flex-row items-center justify-between rounded-lg border p-3 ${
|
||||
theme === option.value
|
||||
? 'border-indigo-500 bg-indigo-50 dark:border-indigo-400 dark:bg-indigo-900/30'
|
||||
: 'border-gray-200 bg-gray-50 dark:border-gray-600 dark:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<View className="flex-row items-center">
|
||||
<Text className="mr-3 text-lg">{option.icon}</Text>
|
||||
<Text
|
||||
className={`font-medium ${
|
||||
theme === option.value
|
||||
? 'text-indigo-700 dark:text-indigo-300'
|
||||
: 'text-gray-900 dark:text-white'
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{theme === option.value && (
|
||||
<Text className="text-lg text-indigo-500 dark:text-indigo-400">✓</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
{theme === option.value && (
|
||||
<Text className="text-lg text-indigo-500 dark:text-indigo-400">✓</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Privacy & Location Section */}
|
||||
<View className="mx-4 mt-4 rounded-lg bg-white p-4 shadow-sm dark:bg-gray-800">
|
||||
<Text className="mb-3 text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Privatsphäre & Standort
|
||||
</Text>
|
||||
{/* Privacy & Location Section */}
|
||||
<View className="mx-4 mt-4 rounded-lg bg-white p-4 shadow-sm dark:bg-gray-800">
|
||||
<Text className="mb-3 text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Privatsphäre & Standort
|
||||
</Text>
|
||||
|
||||
{/* Location Toggle */}
|
||||
<View className="mb-4 flex-row items-center justify-between">
|
||||
<View className="flex-1">
|
||||
<View className="flex-row items-center">
|
||||
<Ionicons name="location-outline" size={20} color="#6b7280" />
|
||||
<Text className="ml-2 font-medium text-gray-900 dark:text-white">
|
||||
Standort speichern
|
||||
</Text>
|
||||
</View>
|
||||
<Text className="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
||||
Speichert den Ort deiner Mahlzeiten für personalisierte Einblicke
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={locationEnabled}
|
||||
onValueChange={handleLocationToggle}
|
||||
disabled={isLoadingPrefs}
|
||||
trackColor={{ false: '#d1d5db', true: '#818cf8' }}
|
||||
thumbColor={locationEnabled ? '#6366f1' : '#f3f4f6'}
|
||||
ios_backgroundColor="#d1d5db"
|
||||
/>
|
||||
</View>
|
||||
{/* Location Toggle */}
|
||||
<View className="mb-4 flex-row items-center justify-between">
|
||||
<View className="flex-1">
|
||||
<View className="flex-row items-center">
|
||||
<Ionicons name="location-outline" size={20} color="#6b7280" />
|
||||
<Text className="ml-2 font-medium text-gray-900 dark:text-white">
|
||||
Standort speichern
|
||||
</Text>
|
||||
</View>
|
||||
<Text className="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
||||
Speichert den Ort deiner Mahlzeiten für personalisierte Einblicke
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={locationEnabled}
|
||||
onValueChange={handleLocationToggle}
|
||||
disabled={isLoadingPrefs}
|
||||
trackColor={{ false: '#d1d5db', true: '#818cf8' }}
|
||||
thumbColor={locationEnabled ? '#6366f1' : '#f3f4f6'}
|
||||
ios_backgroundColor="#d1d5db"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* App Settings Link */}
|
||||
<TouchableOpacity
|
||||
onPress={openAppSettings}
|
||||
className="flex-row items-center justify-between border-t border-gray-200 pt-3 dark:border-gray-600">
|
||||
<View className="flex-1">
|
||||
<Text className="font-medium text-gray-900 dark:text-white">
|
||||
App-Berechtigungen
|
||||
</Text>
|
||||
<Text className="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
||||
Verwalte Kamera- und Standortberechtigungen
|
||||
</Text>
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={20} color="#6b7280" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
{/* App Settings Link */}
|
||||
<TouchableOpacity
|
||||
onPress={openAppSettings}
|
||||
className="flex-row items-center justify-between border-t border-gray-200 pt-3 dark:border-gray-600"
|
||||
>
|
||||
<View className="flex-1">
|
||||
<Text className="font-medium text-gray-900 dark:text-white">
|
||||
App-Berechtigungen
|
||||
</Text>
|
||||
<Text className="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
||||
Verwalte Kamera- und Standortberechtigungen
|
||||
</Text>
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={20} color="#6b7280" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Data Management Section */}
|
||||
<View className="mx-4 mt-4 rounded-lg bg-white p-4 shadow-sm dark:bg-gray-800">
|
||||
<Text className="mb-3 text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Datenverwaltung
|
||||
</Text>
|
||||
{/* Data Management Section */}
|
||||
<View className="mx-4 mt-4 rounded-lg bg-white p-4 shadow-sm dark:bg-gray-800">
|
||||
<Text className="mb-3 text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Datenverwaltung
|
||||
</Text>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={handleDeleteAllData}
|
||||
disabled={isClearing}
|
||||
className={`rounded-lg p-4 ${
|
||||
isClearing ? 'bg-gray-100 dark:bg-gray-700' : 'bg-red-50 dark:bg-red-900/30'
|
||||
}`}>
|
||||
<View className="flex-row items-center justify-between">
|
||||
<View className="flex-1">
|
||||
<Text
|
||||
className={`font-medium ${
|
||||
isClearing
|
||||
? 'text-gray-400 dark:text-gray-500'
|
||||
: 'text-red-700 dark:text-red-300'
|
||||
}`}>
|
||||
Alle Daten löschen
|
||||
</Text>
|
||||
<Text
|
||||
className={`mt-1 text-sm ${
|
||||
isClearing
|
||||
? 'text-gray-400 dark:text-gray-500'
|
||||
: 'text-red-600 dark:text-red-400'
|
||||
}`}>
|
||||
Löscht alle Mahlzeiten, Fotos und Einstellungen
|
||||
</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
onPress={handleDeleteAllData}
|
||||
disabled={isClearing}
|
||||
className={`rounded-lg p-4 ${
|
||||
isClearing ? 'bg-gray-100 dark:bg-gray-700' : 'bg-red-50 dark:bg-red-900/30'
|
||||
}`}
|
||||
>
|
||||
<View className="flex-row items-center justify-between">
|
||||
<View className="flex-1">
|
||||
<Text
|
||||
className={`font-medium ${
|
||||
isClearing
|
||||
? 'text-gray-400 dark:text-gray-500'
|
||||
: 'text-red-700 dark:text-red-300'
|
||||
}`}
|
||||
>
|
||||
Alle Daten löschen
|
||||
</Text>
|
||||
<Text
|
||||
className={`mt-1 text-sm ${
|
||||
isClearing
|
||||
? 'text-gray-400 dark:text-gray-500'
|
||||
: 'text-red-600 dark:text-red-400'
|
||||
}`}
|
||||
>
|
||||
Löscht alle Mahlzeiten, Fotos und Einstellungen
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{isClearing ? (
|
||||
<Text className="ml-3 text-gray-400 dark:text-gray-500">⏳</Text>
|
||||
) : (
|
||||
<Text className="ml-3 text-red-500 dark:text-red-400">🗑️</Text>
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
{isClearing ? (
|
||||
<Text className="ml-3 text-gray-400 dark:text-gray-500">⏳</Text>
|
||||
) : (
|
||||
<Text className="ml-3 text-red-500 dark:text-red-400">🗑️</Text>
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
<View className="mt-3 rounded-lg bg-yellow-50 p-3 dark:bg-yellow-900/30">
|
||||
<Text className="text-sm text-yellow-800 dark:text-yellow-200">
|
||||
⚠️ Diese Aktion kann nicht rückgängig gemacht werden
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className="mt-3 rounded-lg bg-yellow-50 p-3 dark:bg-yellow-900/30">
|
||||
<Text className="text-sm text-yellow-800 dark:text-yellow-200">
|
||||
⚠️ Diese Aktion kann nicht rückgängig gemacht werden
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Footer */}
|
||||
<View className="mx-4 mb-4 mt-8">
|
||||
<Text className="text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
Made with ❤️ for better nutrition tracking
|
||||
</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
{/* Footer */}
|
||||
<View className="mx-4 mb-4 mt-8">
|
||||
<Text className="text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
Made with ❤️ for better nutrition tracking
|
||||
</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
<LoadingOverlay visible={isClearing} message="Alle Daten werden gelöscht..." />
|
||||
</SafeAreaView>
|
||||
</>
|
||||
);
|
||||
<LoadingOverlay visible={isClearing} message="Alle Daten werden gelöscht..." />
|
||||
</SafeAreaView>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
let plugins = [];
|
||||
api.cache(true);
|
||||
let plugins = [];
|
||||
|
||||
return {
|
||||
presets: [['babel-preset-expo', { jsxImportSource: 'nativewind' }], 'nativewind/babel'],
|
||||
return {
|
||||
presets: [['babel-preset-expo', { jsxImportSource: 'nativewind' }], 'nativewind/babel'],
|
||||
|
||||
plugins,
|
||||
};
|
||||
plugins,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,44 +1,44 @@
|
|||
{
|
||||
"cesVersion": "2.18.3",
|
||||
"projectName": "nutriphi",
|
||||
"packages": [
|
||||
{
|
||||
"name": "expo-router",
|
||||
"type": "navigation",
|
||||
"options": {
|
||||
"type": "tabs"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "nativewind",
|
||||
"type": "styling"
|
||||
},
|
||||
{
|
||||
"name": "zustand",
|
||||
"type": "state-management"
|
||||
},
|
||||
{
|
||||
"name": "mana-core-auth",
|
||||
"type": "authentication"
|
||||
}
|
||||
],
|
||||
"flags": {
|
||||
"noGit": false,
|
||||
"noInstall": false,
|
||||
"overwrite": false,
|
||||
"importAlias": true,
|
||||
"packageManager": "npm",
|
||||
"eas": true,
|
||||
"publish": false
|
||||
},
|
||||
"packageManager": {
|
||||
"type": "npm",
|
||||
"version": "10.8.2"
|
||||
},
|
||||
"os": {
|
||||
"type": "Darwin",
|
||||
"platform": "darwin",
|
||||
"arch": "arm64",
|
||||
"kernelVersion": "24.1.0"
|
||||
}
|
||||
"cesVersion": "2.18.3",
|
||||
"projectName": "nutriphi",
|
||||
"packages": [
|
||||
{
|
||||
"name": "expo-router",
|
||||
"type": "navigation",
|
||||
"options": {
|
||||
"type": "tabs"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "nativewind",
|
||||
"type": "styling"
|
||||
},
|
||||
{
|
||||
"name": "zustand",
|
||||
"type": "state-management"
|
||||
},
|
||||
{
|
||||
"name": "mana-core-auth",
|
||||
"type": "authentication"
|
||||
}
|
||||
],
|
||||
"flags": {
|
||||
"noGit": false,
|
||||
"noInstall": false,
|
||||
"overwrite": false,
|
||||
"importAlias": true,
|
||||
"packageManager": "npm",
|
||||
"eas": true,
|
||||
"publish": false
|
||||
},
|
||||
"packageManager": {
|
||||
"type": "npm",
|
||||
"version": "10.8.2"
|
||||
},
|
||||
"os": {
|
||||
"type": "Darwin",
|
||||
"platform": "darwin",
|
||||
"arch": "arm64",
|
||||
"kernelVersion": "24.1.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,23 +2,24 @@ import { forwardRef } from 'react';
|
|||
import { Text, TouchableOpacity, TouchableOpacityProps, View } from 'react-native';
|
||||
|
||||
type ButtonProps = {
|
||||
title: string;
|
||||
title: string;
|
||||
} & TouchableOpacityProps;
|
||||
|
||||
export const Button = forwardRef<View, ButtonProps>(({ title, ...touchableProps }, ref) => {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
ref={ref}
|
||||
{...touchableProps}
|
||||
className={`${styles.button} ${touchableProps.className}`}>
|
||||
<Text className={styles.buttonText}>{title}</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
return (
|
||||
<TouchableOpacity
|
||||
ref={ref}
|
||||
{...touchableProps}
|
||||
className={`${styles.button} ${touchableProps.className}`}
|
||||
>
|
||||
<Text className={styles.buttonText}>{title}</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
});
|
||||
|
||||
Button.displayName = 'Button';
|
||||
|
||||
const styles = {
|
||||
button: 'items-center bg-indigo-500 rounded-[28px] shadow-md p-4',
|
||||
buttonText: 'text-white text-lg font-semibold text-center',
|
||||
button: 'items-center bg-indigo-500 rounded-[28px] shadow-md p-4',
|
||||
buttonText: 'text-white text-lg font-semibold text-center',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { SafeAreaView } from 'react-native';
|
||||
|
||||
export const Container = ({ children }: { children: React.ReactNode }) => {
|
||||
return <SafeAreaView className={styles.container}>{children}</SafeAreaView>;
|
||||
return <SafeAreaView className={styles.container}>{children}</SafeAreaView>;
|
||||
};
|
||||
|
||||
const styles = {
|
||||
container: 'flex flex-1 m-6',
|
||||
container: 'flex flex-1 m-6',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,29 +1,29 @@
|
|||
import { Text, View } from 'react-native';
|
||||
|
||||
export const EditScreenInfo = ({ path }: { path: string }) => {
|
||||
const title = 'Open up the code for this screen:';
|
||||
const description =
|
||||
'Change any of the text, save the file, and your app will automatically update.';
|
||||
const title = 'Open up the code for this screen:';
|
||||
const description =
|
||||
'Change any of the text, save the file, and your app will automatically update.';
|
||||
|
||||
return (
|
||||
<View>
|
||||
<View className={styles.getStartedContainer}>
|
||||
<Text className={styles.getStartedText}>{title}</Text>
|
||||
<View className={styles.codeHighlightContainer + styles.homeScreenFilename}>
|
||||
<Text>{path}</Text>
|
||||
</View>
|
||||
<Text className={styles.getStartedText}>{description}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
return (
|
||||
<View>
|
||||
<View className={styles.getStartedContainer}>
|
||||
<Text className={styles.getStartedText}>{title}</Text>
|
||||
<View className={styles.codeHighlightContainer + styles.homeScreenFilename}>
|
||||
<Text>{path}</Text>
|
||||
</View>
|
||||
<Text className={styles.getStartedText}>{description}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = {
|
||||
codeHighlightContainer: `rounded-md px-1`,
|
||||
getStartedContainer: `items-center mx-12`,
|
||||
getStartedText: `text-lg leading-6 text-center`,
|
||||
helpContainer: `items-center mx-5 mt-4`,
|
||||
helpLink: `py-4`,
|
||||
helpLinkText: `text-center`,
|
||||
homeScreenFilename: `my-2`,
|
||||
codeHighlightContainer: `rounded-md px-1`,
|
||||
getStartedContainer: `items-center mx-12`,
|
||||
getStartedText: `text-lg leading-6 text-center`,
|
||||
helpContainer: `items-center mx-5 mt-4`,
|
||||
helpLink: `py-4`,
|
||||
helpLinkText: `text-center`,
|
||||
homeScreenFilename: `my-2`,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,31 +3,31 @@ import FontAwesome from '@expo/vector-icons/FontAwesome';
|
|||
import { Pressable, StyleSheet } from 'react-native';
|
||||
|
||||
export const HeaderButton = forwardRef<typeof Pressable, { onPress?: () => void }>(
|
||||
({ onPress }, ref) => {
|
||||
return (
|
||||
<Pressable onPress={onPress}>
|
||||
{({ pressed }) => (
|
||||
<FontAwesome
|
||||
name="info-circle"
|
||||
size={25}
|
||||
color="gray"
|
||||
style={[
|
||||
styles.headerRight,
|
||||
{
|
||||
opacity: pressed ? 0.5 : 1,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
({ onPress }, ref) => {
|
||||
return (
|
||||
<Pressable onPress={onPress}>
|
||||
{({ pressed }) => (
|
||||
<FontAwesome
|
||||
name="info-circle"
|
||||
size={25}
|
||||
color="gray"
|
||||
style={[
|
||||
styles.headerRight,
|
||||
{
|
||||
opacity: pressed ? 0.5 : 1,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
HeaderButton.displayName = 'HeaderButton';
|
||||
|
||||
export const styles = StyleSheet.create({
|
||||
headerRight: {
|
||||
marginRight: 15,
|
||||
},
|
||||
headerRight: {
|
||||
marginRight: 15,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,23 +3,23 @@ import { Text, View } from 'react-native';
|
|||
import { EditScreenInfo } from './EditScreenInfo';
|
||||
|
||||
type ScreenContentProps = {
|
||||
title: string;
|
||||
path: string;
|
||||
children?: React.ReactNode;
|
||||
title: string;
|
||||
path: string;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const ScreenContent = ({ title, path, children }: ScreenContentProps) => {
|
||||
return (
|
||||
<View className={styles.container}>
|
||||
<Text className={styles.title}>{title}</Text>
|
||||
<View className={styles.separator} />
|
||||
<EditScreenInfo path={path} />
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
return (
|
||||
<View className={styles.container}>
|
||||
<Text className={styles.title}>{title}</Text>
|
||||
<View className={styles.separator} />
|
||||
<EditScreenInfo path={path} />
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
const styles = {
|
||||
container: `items-center flex-1 justify-center`,
|
||||
separator: `h-[1px] my-7 w-4/5 bg-gray-200`,
|
||||
title: `text-xl font-bold`,
|
||||
container: `items-center flex-1 justify-center`,
|
||||
separator: `h-[1px] my-7 w-4/5 bg-gray-200`,
|
||||
title: `text-xl font-bold`,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,25 +2,25 @@ import { StyleSheet } from 'react-native';
|
|||
import { SFSymbol } from './ui/SFSymbol';
|
||||
|
||||
interface TabBarIconProps {
|
||||
sfSymbol: string;
|
||||
fallbackIcon: string;
|
||||
color: string;
|
||||
sfSymbol: string;
|
||||
fallbackIcon: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export const TabBarIcon = ({ sfSymbol, fallbackIcon, color }: TabBarIconProps) => {
|
||||
return (
|
||||
<SFSymbol
|
||||
name={sfSymbol}
|
||||
fallbackIcon={fallbackIcon as any}
|
||||
color={color}
|
||||
size={24}
|
||||
style={styles.tabBarIcon}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<SFSymbol
|
||||
name={sfSymbol}
|
||||
fallbackIcon={fallbackIcon as any}
|
||||
color={color}
|
||||
size={24}
|
||||
style={styles.tabBarIcon}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const styles = StyleSheet.create({
|
||||
tabBarIcon: {
|
||||
marginBottom: -3,
|
||||
},
|
||||
tabBarIcon: {
|
||||
marginBottom: -3,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -17,404 +17,407 @@ import { UserPreferencesService } from '../../services/UserPreferencesService';
|
|||
import { LocationPermissionModal } from '../location/LocationPermissionModal';
|
||||
|
||||
interface CameraModalProps {
|
||||
mode: 'camera' | 'gallery';
|
||||
mode: 'camera' | 'gallery';
|
||||
}
|
||||
|
||||
export const CameraModal: React.FC<CameraModalProps> = ({ mode }) => {
|
||||
const [capturedPhoto, setCapturedPhoto] = useState<{
|
||||
uri: string;
|
||||
path: string;
|
||||
size: number;
|
||||
dimensions: any;
|
||||
} | null>(null);
|
||||
const [isGalleryLoading, setIsGalleryLoading] = useState(false);
|
||||
const [showLocationPermission, setShowLocationPermission] = useState(false);
|
||||
const [capturedPhoto, setCapturedPhoto] = useState<{
|
||||
uri: string;
|
||||
path: string;
|
||||
size: number;
|
||||
dimensions: any;
|
||||
} | null>(null);
|
||||
const [isGalleryLoading, setIsGalleryLoading] = useState(false);
|
||||
const [showLocationPermission, setShowLocationPermission] = useState(false);
|
||||
|
||||
const { showCameraModal, toggleCameraModal, setPhotoProcessing } = useAppStore();
|
||||
const { createMeal, updateMeal, createFoodItemsBatch } = useMealStore();
|
||||
const { showCameraModal, toggleCameraModal, setPhotoProcessing } = useAppStore();
|
||||
const { createMeal, updateMeal, createFoodItemsBatch } = useMealStore();
|
||||
|
||||
const {
|
||||
hasPermission,
|
||||
canAskPermission,
|
||||
requestPermission,
|
||||
isReady,
|
||||
setIsReady,
|
||||
isCapturing,
|
||||
facing,
|
||||
cameraRef,
|
||||
toggleCameraFacing,
|
||||
takePicture,
|
||||
pickImageFromGallery,
|
||||
} = useCamera();
|
||||
const {
|
||||
hasPermission,
|
||||
canAskPermission,
|
||||
requestPermission,
|
||||
isReady,
|
||||
setIsReady,
|
||||
isCapturing,
|
||||
facing,
|
||||
cameraRef,
|
||||
toggleCameraFacing,
|
||||
takePicture,
|
||||
pickImageFromGallery,
|
||||
} = useCamera();
|
||||
|
||||
const handleClose = () => {
|
||||
setCapturedPhoto(null);
|
||||
setIsGalleryLoading(false);
|
||||
toggleCameraModal(false);
|
||||
};
|
||||
const handleClose = () => {
|
||||
setCapturedPhoto(null);
|
||||
setIsGalleryLoading(false);
|
||||
toggleCameraModal(false);
|
||||
};
|
||||
|
||||
const handleTakePicture = async () => {
|
||||
try {
|
||||
const photo = await takePicture();
|
||||
if (photo) {
|
||||
setCapturedPhoto(photo);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to take picture:', error);
|
||||
// TODO: Show error toast
|
||||
}
|
||||
};
|
||||
const handleTakePicture = async () => {
|
||||
try {
|
||||
const photo = await takePicture();
|
||||
if (photo) {
|
||||
setCapturedPhoto(photo);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to take picture:', error);
|
||||
// TODO: Show error toast
|
||||
}
|
||||
};
|
||||
|
||||
const handleRetake = () => {
|
||||
setCapturedPhoto(null);
|
||||
};
|
||||
const handleRetake = () => {
|
||||
setCapturedPhoto(null);
|
||||
};
|
||||
|
||||
const handleLocationPermissionAllow = async () => {
|
||||
const prefsService = UserPreferencesService.getInstance();
|
||||
const locationService = LocationService.getInstance();
|
||||
const handleLocationPermissionAllow = async () => {
|
||||
const prefsService = UserPreferencesService.getInstance();
|
||||
const locationService = LocationService.getInstance();
|
||||
|
||||
// Mark that we've asked
|
||||
await prefsService.markLocationPermissionAsked();
|
||||
// Mark that we've asked
|
||||
await prefsService.markLocationPermissionAsked();
|
||||
|
||||
// Request permission
|
||||
const granted = await locationService.requestPermissions();
|
||||
// Request permission
|
||||
const granted = await locationService.requestPermissions();
|
||||
|
||||
if (granted) {
|
||||
await prefsService.setLocationEnabled(true);
|
||||
} else {
|
||||
await prefsService.setLocationEnabled(false);
|
||||
}
|
||||
if (granted) {
|
||||
await prefsService.setLocationEnabled(true);
|
||||
} else {
|
||||
await prefsService.setLocationEnabled(false);
|
||||
}
|
||||
|
||||
setShowLocationPermission(false);
|
||||
setShowLocationPermission(false);
|
||||
|
||||
// Continue with photo processing
|
||||
if (capturedPhoto) {
|
||||
handleUsePhoto();
|
||||
}
|
||||
};
|
||||
// Continue with photo processing
|
||||
if (capturedPhoto) {
|
||||
handleUsePhoto();
|
||||
}
|
||||
};
|
||||
|
||||
const handleLocationPermissionDeny = async () => {
|
||||
const prefsService = UserPreferencesService.getInstance();
|
||||
const handleLocationPermissionDeny = async () => {
|
||||
const prefsService = UserPreferencesService.getInstance();
|
||||
|
||||
// Mark that we've asked and user denied
|
||||
await prefsService.markLocationPermissionAsked();
|
||||
await prefsService.setLocationEnabled(false);
|
||||
// Mark that we've asked and user denied
|
||||
await prefsService.markLocationPermissionAsked();
|
||||
await prefsService.setLocationEnabled(false);
|
||||
|
||||
setShowLocationPermission(false);
|
||||
setShowLocationPermission(false);
|
||||
|
||||
// Continue with photo processing without location
|
||||
if (capturedPhoto) {
|
||||
handleUsePhoto();
|
||||
}
|
||||
};
|
||||
// Continue with photo processing without location
|
||||
if (capturedPhoto) {
|
||||
handleUsePhoto();
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-trigger gallery picker when mode is 'gallery'
|
||||
React.useEffect(() => {
|
||||
if (showCameraModal && mode === 'gallery' && !capturedPhoto && !isGalleryLoading) {
|
||||
const pickFromGallery = async () => {
|
||||
try {
|
||||
setIsGalleryLoading(true);
|
||||
const photo = await pickImageFromGallery();
|
||||
if (photo) {
|
||||
setCapturedPhoto(photo);
|
||||
} else {
|
||||
// User cancelled, close modal
|
||||
toggleCameraModal(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to pick image from gallery:', error);
|
||||
toggleCameraModal(false);
|
||||
} finally {
|
||||
setIsGalleryLoading(false);
|
||||
}
|
||||
};
|
||||
pickFromGallery();
|
||||
}
|
||||
}, [
|
||||
showCameraModal,
|
||||
mode,
|
||||
capturedPhoto,
|
||||
isGalleryLoading,
|
||||
pickImageFromGallery,
|
||||
toggleCameraModal,
|
||||
]);
|
||||
// Auto-trigger gallery picker when mode is 'gallery'
|
||||
React.useEffect(() => {
|
||||
if (showCameraModal && mode === 'gallery' && !capturedPhoto && !isGalleryLoading) {
|
||||
const pickFromGallery = async () => {
|
||||
try {
|
||||
setIsGalleryLoading(true);
|
||||
const photo = await pickImageFromGallery();
|
||||
if (photo) {
|
||||
setCapturedPhoto(photo);
|
||||
} else {
|
||||
// User cancelled, close modal
|
||||
toggleCameraModal(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to pick image from gallery:', error);
|
||||
toggleCameraModal(false);
|
||||
} finally {
|
||||
setIsGalleryLoading(false);
|
||||
}
|
||||
};
|
||||
pickFromGallery();
|
||||
}
|
||||
}, [
|
||||
showCameraModal,
|
||||
mode,
|
||||
capturedPhoto,
|
||||
isGalleryLoading,
|
||||
pickImageFromGallery,
|
||||
toggleCameraModal,
|
||||
]);
|
||||
|
||||
const handleUsePhoto = async () => {
|
||||
if (!capturedPhoto) return;
|
||||
const handleUsePhoto = async () => {
|
||||
if (!capturedPhoto) return;
|
||||
|
||||
try {
|
||||
setPhotoProcessing(true);
|
||||
try {
|
||||
setPhotoProcessing(true);
|
||||
|
||||
// Check location preferences and permissions
|
||||
let locationInfo: any = {};
|
||||
|
||||
try {
|
||||
const prefsService = UserPreferencesService.getInstance();
|
||||
const locationEnabled = await prefsService.isLocationEnabled();
|
||||
// Check location preferences and permissions
|
||||
let locationInfo: any = {};
|
||||
|
||||
if (locationEnabled) {
|
||||
const locationService = LocationService.getInstance();
|
||||
try {
|
||||
const prefsService = UserPreferencesService.getInstance();
|
||||
const locationEnabled = await prefsService.isLocationEnabled();
|
||||
|
||||
// Check if we need to ask for permission first time
|
||||
const hasAskedBefore = await prefsService.hasAskedLocationPermission();
|
||||
if (!hasAskedBefore) {
|
||||
const hasPermission = await locationService.checkPermissions();
|
||||
if (!hasPermission) {
|
||||
// Show permission modal
|
||||
setShowLocationPermission(true);
|
||||
setPhotoProcessing(false);
|
||||
return; // Wait for user response
|
||||
}
|
||||
}
|
||||
if (locationEnabled) {
|
||||
const locationService = LocationService.getInstance();
|
||||
|
||||
// Get location
|
||||
try {
|
||||
const locationData = await locationService.getCurrentLocation();
|
||||
if (locationData && locationData.latitude && locationData.longitude) {
|
||||
locationInfo = {
|
||||
latitude: locationData.latitude,
|
||||
longitude: locationData.longitude,
|
||||
location_accuracy: locationData.accuracy,
|
||||
location: locationData.address
|
||||
? locationService.formatLocationForDisplay(locationData.address)
|
||||
: undefined,
|
||||
};
|
||||
console.log('Location captured:', locationInfo);
|
||||
}
|
||||
} catch (locationError) {
|
||||
console.warn('Failed to get location:', locationError);
|
||||
// Continue without location
|
||||
}
|
||||
}
|
||||
} catch (prefsError) {
|
||||
console.error('Failed to check location preferences:', prefsError);
|
||||
// Continue without location
|
||||
}
|
||||
// Check if we need to ask for permission first time
|
||||
const hasAskedBefore = await prefsService.hasAskedLocationPermission();
|
||||
if (!hasAskedBefore) {
|
||||
const hasPermission = await locationService.checkPermissions();
|
||||
if (!hasPermission) {
|
||||
// Show permission modal
|
||||
setShowLocationPermission(true);
|
||||
setPhotoProcessing(false);
|
||||
return; // Wait for user response
|
||||
}
|
||||
}
|
||||
|
||||
// Create meal record with initial data including location
|
||||
const mealId = await createMeal({
|
||||
photo_path: capturedPhoto.path,
|
||||
photo_size: capturedPhoto.size,
|
||||
photo_dimensions: capturedPhoto.dimensions,
|
||||
meal_type: 'lunch', // Default, will be updated by AI
|
||||
analysis_status: 'pending',
|
||||
...locationInfo,
|
||||
});
|
||||
// Get location
|
||||
try {
|
||||
const locationData = await locationService.getCurrentLocation();
|
||||
if (locationData && locationData.latitude && locationData.longitude) {
|
||||
locationInfo = {
|
||||
latitude: locationData.latitude,
|
||||
longitude: locationData.longitude,
|
||||
location_accuracy: locationData.accuracy,
|
||||
location: locationData.address
|
||||
? locationService.formatLocationForDisplay(locationData.address)
|
||||
: undefined,
|
||||
};
|
||||
console.log('Location captured:', locationInfo);
|
||||
}
|
||||
} catch (locationError) {
|
||||
console.warn('Failed to get location:', locationError);
|
||||
// Continue without location
|
||||
}
|
||||
}
|
||||
} catch (prefsError) {
|
||||
console.error('Failed to check location preferences:', prefsError);
|
||||
// Continue without location
|
||||
}
|
||||
|
||||
console.log('Meal created with ID:', mealId);
|
||||
// Create meal record with initial data including location
|
||||
const mealId = await createMeal({
|
||||
photo_path: capturedPhoto.path,
|
||||
photo_size: capturedPhoto.size,
|
||||
photo_dimensions: capturedPhoto.dimensions,
|
||||
meal_type: 'lunch', // Default, will be updated by AI
|
||||
analysis_status: 'pending',
|
||||
...locationInfo,
|
||||
});
|
||||
|
||||
// Convert temporary photo to permanent storage
|
||||
const photoService = PhotoService.getInstance();
|
||||
const permanentPhoto = await photoService.makePhotoPermanent(capturedPhoto.path, mealId);
|
||||
console.log('Meal created with ID:', mealId);
|
||||
|
||||
// Update meal record with permanent photo path
|
||||
await updateMeal(mealId, {
|
||||
photo_path: permanentPhoto.path,
|
||||
photo_size: permanentPhoto.size,
|
||||
photo_dimensions: permanentPhoto.dimensions,
|
||||
});
|
||||
// Convert temporary photo to permanent storage
|
||||
const photoService = PhotoService.getInstance();
|
||||
const permanentPhoto = await photoService.makePhotoPermanent(capturedPhoto.path, mealId);
|
||||
|
||||
console.log('Photo converted to permanent storage:', permanentPhoto.path);
|
||||
// Update meal record with permanent photo path
|
||||
await updateMeal(mealId, {
|
||||
photo_path: permanentPhoto.path,
|
||||
photo_size: permanentPhoto.size,
|
||||
photo_dimensions: permanentPhoto.dimensions,
|
||||
});
|
||||
|
||||
// Close modal immediately, analysis will happen in background
|
||||
handleClose();
|
||||
console.log('Photo converted to permanent storage:', permanentPhoto.path);
|
||||
|
||||
// Start AI analysis in background
|
||||
try {
|
||||
console.log('Starting Gemini analysis...');
|
||||
const geminiService = GeminiService.getInstance();
|
||||
// Close modal immediately, analysis will happen in background
|
||||
handleClose();
|
||||
|
||||
// Get current time for meal type context
|
||||
const hour = new Date().getHours();
|
||||
let mealTypeContext: 'breakfast' | 'lunch' | 'dinner' | 'snack' = 'lunch';
|
||||
// Start AI analysis in background
|
||||
try {
|
||||
console.log('Starting Gemini analysis...');
|
||||
const geminiService = GeminiService.getInstance();
|
||||
|
||||
if (hour >= 5 && hour < 11) mealTypeContext = 'breakfast';
|
||||
else if (hour >= 11 && hour < 16) mealTypeContext = 'lunch';
|
||||
else if (hour >= 16 && hour < 22) mealTypeContext = 'dinner';
|
||||
else mealTypeContext = 'snack';
|
||||
// Get current time for meal type context
|
||||
const hour = new Date().getHours();
|
||||
let mealTypeContext: 'breakfast' | 'lunch' | 'dinner' | 'snack' = 'lunch';
|
||||
|
||||
const analysisResult = await geminiService.analyzeFoodImage(permanentPhoto.path, {
|
||||
mealType: mealTypeContext,
|
||||
});
|
||||
if (hour >= 5 && hour < 11) mealTypeContext = 'breakfast';
|
||||
else if (hour >= 11 && hour < 16) mealTypeContext = 'lunch';
|
||||
else if (hour >= 16 && hour < 22) mealTypeContext = 'dinner';
|
||||
else mealTypeContext = 'snack';
|
||||
|
||||
console.log('Gemini analysis completed:', analysisResult);
|
||||
const analysisResult = await geminiService.analyzeFoodImage(permanentPhoto.path, {
|
||||
mealType: mealTypeContext,
|
||||
});
|
||||
|
||||
// Update meal with AI analysis results
|
||||
await updateMeal(mealId, {
|
||||
// Aggregate nutrition data
|
||||
total_calories: analysisResult.meal_analysis.total_calories,
|
||||
total_protein: analysisResult.meal_analysis.total_protein,
|
||||
total_carbs: analysisResult.meal_analysis.total_carbs,
|
||||
total_fat: analysisResult.meal_analysis.total_fat,
|
||||
total_fiber: analysisResult.meal_analysis.total_fiber || 0,
|
||||
total_sugar: analysisResult.meal_analysis.total_sugar || 0,
|
||||
console.log('Gemini analysis completed:', analysisResult);
|
||||
|
||||
// Health assessment
|
||||
health_score: analysisResult.meal_analysis.health_score,
|
||||
health_category: analysisResult.meal_analysis.health_category,
|
||||
// Update meal with AI analysis results
|
||||
await updateMeal(mealId, {
|
||||
// Aggregate nutrition data
|
||||
total_calories: analysisResult.meal_analysis.total_calories,
|
||||
total_protein: analysisResult.meal_analysis.total_protein,
|
||||
total_carbs: analysisResult.meal_analysis.total_carbs,
|
||||
total_fat: analysisResult.meal_analysis.total_fat,
|
||||
total_fiber: analysisResult.meal_analysis.total_fiber || 0,
|
||||
total_sugar: analysisResult.meal_analysis.total_sugar || 0,
|
||||
|
||||
// AI metadata
|
||||
analysis_result: JSON.stringify(analysisResult),
|
||||
analysis_confidence: analysisResult.meal_analysis.confidence,
|
||||
analysis_status: 'completed',
|
||||
meal_type: analysisResult.meal_analysis.meal_type_suggestion || mealTypeContext,
|
||||
// Health assessment
|
||||
health_score: analysisResult.meal_analysis.health_score,
|
||||
health_category: analysisResult.meal_analysis.health_category,
|
||||
|
||||
// API metadata
|
||||
api_provider: 'gemini',
|
||||
processing_time: analysisResult._metadata?.processingTime || 0,
|
||||
});
|
||||
// AI metadata
|
||||
analysis_result: JSON.stringify(analysisResult),
|
||||
analysis_confidence: analysisResult.meal_analysis.confidence,
|
||||
analysis_status: 'completed',
|
||||
meal_type: analysisResult.meal_analysis.meal_type_suggestion || mealTypeContext,
|
||||
|
||||
// Create all food items in a single batch
|
||||
const foodItemsToCreate = analysisResult.food_items.map((item) => ({
|
||||
meal_id: mealId,
|
||||
name: item.name,
|
||||
category: item.category,
|
||||
portion_size: item.portion_size,
|
||||
calories: item.calories,
|
||||
protein: item.protein,
|
||||
carbs: item.carbs,
|
||||
fat: item.fat,
|
||||
fiber: item.fiber || 0,
|
||||
sugar: item.sugar || 0,
|
||||
confidence: item.confidence,
|
||||
is_organic: item.is_organic ? 1 : 0,
|
||||
is_processed: item.is_processed ? 1 : 0,
|
||||
allergens: JSON.stringify(item.allergens || []),
|
||||
}));
|
||||
// API metadata
|
||||
api_provider: 'gemini',
|
||||
processing_time: analysisResult._metadata?.processingTime || 0,
|
||||
});
|
||||
|
||||
await createFoodItemsBatch(foodItemsToCreate);
|
||||
// Create all food items in a single batch
|
||||
const foodItemsToCreate = analysisResult.food_items.map((item) => ({
|
||||
meal_id: mealId,
|
||||
name: item.name,
|
||||
category: item.category,
|
||||
portion_size: item.portion_size,
|
||||
calories: item.calories,
|
||||
protein: item.protein,
|
||||
carbs: item.carbs,
|
||||
fat: item.fat,
|
||||
fiber: item.fiber || 0,
|
||||
sugar: item.sugar || 0,
|
||||
confidence: item.confidence,
|
||||
is_organic: item.is_organic ? 1 : 0,
|
||||
is_processed: item.is_processed ? 1 : 0,
|
||||
allergens: JSON.stringify(item.allergens || []),
|
||||
}));
|
||||
|
||||
console.log('Meal analysis completed and saved to database');
|
||||
} catch (analysisError) {
|
||||
console.error('AI analysis failed:', analysisError);
|
||||
await createFoodItemsBatch(foodItemsToCreate);
|
||||
|
||||
// Update meal status to failed
|
||||
await updateMeal(mealId, {
|
||||
analysis_status: 'failed',
|
||||
analysis_result: JSON.stringify({
|
||||
error: analysisError instanceof Error ? analysisError.message : 'Unknown error',
|
||||
timestamp: new Date().toISOString(),
|
||||
}),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save meal:', error);
|
||||
// TODO: Show error toast
|
||||
} finally {
|
||||
setPhotoProcessing(false);
|
||||
}
|
||||
};
|
||||
console.log('Meal analysis completed and saved to database');
|
||||
} catch (analysisError) {
|
||||
console.error('AI analysis failed:', analysisError);
|
||||
|
||||
const renderPermissionRequest = () => (
|
||||
<View className="flex-1 items-center justify-center bg-black">
|
||||
<View className="items-center space-y-6 px-8">
|
||||
<Text className="text-6xl">📷</Text>
|
||||
<Text className="text-center text-xl font-semibold text-white">
|
||||
Camera Permission Required
|
||||
</Text>
|
||||
<Text className="text-center text-gray-300">
|
||||
Nutriphi needs camera access to take photos of your meals for nutritional analysis.
|
||||
</Text>
|
||||
// Update meal status to failed
|
||||
await updateMeal(mealId, {
|
||||
analysis_status: 'failed',
|
||||
analysis_result: JSON.stringify({
|
||||
error: analysisError instanceof Error ? analysisError.message : 'Unknown error',
|
||||
timestamp: new Date().toISOString(),
|
||||
}),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save meal:', error);
|
||||
// TODO: Show error toast
|
||||
} finally {
|
||||
setPhotoProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
{canAskPermission ? (
|
||||
<Button title="Grant Permission" onPress={requestPermission} className="px-8" />
|
||||
) : (
|
||||
<View className="items-center space-y-4">
|
||||
<Text className="text-center text-sm text-gray-300">
|
||||
Camera permission was denied. Please enable it in your device settings.
|
||||
</Text>
|
||||
<Button title="Close" onPress={handleClose} className="px-8" />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
const renderPermissionRequest = () => (
|
||||
<View className="flex-1 items-center justify-center bg-black">
|
||||
<View className="items-center space-y-6 px-8">
|
||||
<Text className="text-6xl">📷</Text>
|
||||
<Text className="text-center text-xl font-semibold text-white">
|
||||
Camera Permission Required
|
||||
</Text>
|
||||
<Text className="text-center text-gray-300">
|
||||
Nutriphi needs camera access to take photos of your meals for nutritional analysis.
|
||||
</Text>
|
||||
|
||||
const renderCamera = () => (
|
||||
<View className="flex-1">
|
||||
<CameraView
|
||||
ref={cameraRef}
|
||||
style={{ flex: 1 }}
|
||||
facing={facing}
|
||||
onCameraReady={() => setIsReady(true)}>
|
||||
{/* Header */}
|
||||
<SafeAreaView className="absolute left-0 right-0 top-0 z-10">
|
||||
<View className="flex-row items-center justify-between px-6 py-4">
|
||||
<TouchableOpacity
|
||||
onPress={handleClose}
|
||||
className="h-10 w-10 items-center justify-center rounded-full bg-black/50">
|
||||
<Text className="text-lg text-white">✕</Text>
|
||||
</TouchableOpacity>
|
||||
{canAskPermission ? (
|
||||
<Button title="Grant Permission" onPress={requestPermission} className="px-8" />
|
||||
) : (
|
||||
<View className="items-center space-y-4">
|
||||
<Text className="text-center text-sm text-gray-300">
|
||||
Camera permission was denied. Please enable it in your device settings.
|
||||
</Text>
|
||||
<Button title="Close" onPress={handleClose} className="px-8" />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
<Text className="text-lg font-semibold text-white">Take a Photo</Text>
|
||||
const renderCamera = () => (
|
||||
<View className="flex-1">
|
||||
<CameraView
|
||||
ref={cameraRef}
|
||||
style={{ flex: 1 }}
|
||||
facing={facing}
|
||||
onCameraReady={() => setIsReady(true)}
|
||||
>
|
||||
{/* Header */}
|
||||
<SafeAreaView className="absolute left-0 right-0 top-0 z-10">
|
||||
<View className="flex-row items-center justify-between px-6 py-4">
|
||||
<TouchableOpacity
|
||||
onPress={handleClose}
|
||||
className="h-10 w-10 items-center justify-center rounded-full bg-black/50"
|
||||
>
|
||||
<Text className="text-lg text-white">✕</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={toggleCameraFacing}
|
||||
className="h-10 w-10 items-center justify-center rounded-full bg-black/50">
|
||||
<Text className="text-lg text-white">🔄</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
<Text className="text-lg font-semibold text-white">Take a Photo</Text>
|
||||
|
||||
{/* Bottom Controls */}
|
||||
<View className="absolute bottom-0 left-0 right-0">
|
||||
<SafeAreaView className="items-center pb-8">
|
||||
<View className="items-center space-y-4">
|
||||
<Text className="px-8 text-center text-sm text-white">
|
||||
Position your food in the frame and tap the capture button
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
onPress={toggleCameraFacing}
|
||||
className="h-10 w-10 items-center justify-center rounded-full bg-black/50"
|
||||
>
|
||||
<Text className="text-lg text-white">🔄</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
|
||||
<PhotoButton
|
||||
onPress={handleTakePicture}
|
||||
disabled={!isReady}
|
||||
isCapturing={isCapturing}
|
||||
/>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
</View>
|
||||
</CameraView>
|
||||
</View>
|
||||
);
|
||||
{/* Bottom Controls */}
|
||||
<View className="absolute bottom-0 left-0 right-0">
|
||||
<SafeAreaView className="items-center pb-8">
|
||||
<View className="items-center space-y-4">
|
||||
<Text className="px-8 text-center text-sm text-white">
|
||||
Position your food in the frame and tap the capture button
|
||||
</Text>
|
||||
|
||||
if (!showCameraModal) return null;
|
||||
<PhotoButton
|
||||
onPress={handleTakePicture}
|
||||
disabled={!isReady}
|
||||
isCapturing={isCapturing}
|
||||
/>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
</View>
|
||||
</CameraView>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal visible={showCameraModal} animationType="slide" presentationStyle="fullScreen">
|
||||
<StatusBar barStyle="light-content" backgroundColor="black" />
|
||||
if (!showCameraModal) return null;
|
||||
|
||||
{capturedPhoto ? (
|
||||
<PhotoPreview uri={capturedPhoto.uri} onRetake={handleRetake} onUse={handleUsePhoto} />
|
||||
) : mode === 'camera' ? (
|
||||
hasPermission ? (
|
||||
renderCamera()
|
||||
) : (
|
||||
renderPermissionRequest()
|
||||
)
|
||||
) : (
|
||||
// Gallery mode - show loading while picking or error state
|
||||
<View className="flex-1 items-center justify-center bg-black">
|
||||
{isGalleryLoading ? (
|
||||
<LoadingSpinner text="Opening gallery..." color="#ffffff" />
|
||||
) : (
|
||||
<View className="items-center space-y-6 px-8">
|
||||
<Text className="text-6xl">🖼️</Text>
|
||||
<Text className="text-center text-xl font-semibold text-white">Gallery Access</Text>
|
||||
<Text className="text-center text-gray-300">
|
||||
Please wait while we access your photo library...
|
||||
</Text>
|
||||
<Button title="Cancel" onPress={handleClose} className="px-8" />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</Modal>
|
||||
return (
|
||||
<>
|
||||
<Modal visible={showCameraModal} animationType="slide" presentationStyle="fullScreen">
|
||||
<StatusBar barStyle="light-content" backgroundColor="black" />
|
||||
|
||||
<LocationPermissionModal
|
||||
visible={showLocationPermission}
|
||||
onAllow={handleLocationPermissionAllow}
|
||||
onDeny={handleLocationPermissionDeny}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
{capturedPhoto ? (
|
||||
<PhotoPreview uri={capturedPhoto.uri} onRetake={handleRetake} onUse={handleUsePhoto} />
|
||||
) : mode === 'camera' ? (
|
||||
hasPermission ? (
|
||||
renderCamera()
|
||||
) : (
|
||||
renderPermissionRequest()
|
||||
)
|
||||
) : (
|
||||
// Gallery mode - show loading while picking or error state
|
||||
<View className="flex-1 items-center justify-center bg-black">
|
||||
{isGalleryLoading ? (
|
||||
<LoadingSpinner text="Opening gallery..." color="#ffffff" />
|
||||
) : (
|
||||
<View className="items-center space-y-6 px-8">
|
||||
<Text className="text-6xl">🖼️</Text>
|
||||
<Text className="text-center text-xl font-semibold text-white">Gallery Access</Text>
|
||||
<Text className="text-center text-gray-300">
|
||||
Please wait while we access your photo library...
|
||||
</Text>
|
||||
<Button title="Cancel" onPress={handleClose} className="px-8" />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<LocationPermissionModal
|
||||
visible={showLocationPermission}
|
||||
onAllow={handleLocationPermissionAllow}
|
||||
onDeny={handleLocationPermissionDeny}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,75 +1,78 @@
|
|||
import React from 'react';
|
||||
import { TouchableOpacity, View, Text } from 'react-native';
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withSpring,
|
||||
interpolate,
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withSpring,
|
||||
interpolate,
|
||||
} from 'react-native-reanimated';
|
||||
|
||||
interface PhotoButtonProps {
|
||||
onPress: () => void;
|
||||
disabled?: boolean;
|
||||
isCapturing?: boolean;
|
||||
onPress: () => void;
|
||||
disabled?: boolean;
|
||||
isCapturing?: boolean;
|
||||
}
|
||||
|
||||
const AnimatedTouchableOpacity = Animated.createAnimatedComponent(TouchableOpacity);
|
||||
|
||||
export const PhotoButton: React.FC<PhotoButtonProps> = ({
|
||||
onPress,
|
||||
disabled = false,
|
||||
isCapturing = false,
|
||||
onPress,
|
||||
disabled = false,
|
||||
isCapturing = false,
|
||||
}) => {
|
||||
const pressed = useSharedValue(false);
|
||||
const pressed = useSharedValue(false);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
const scale = interpolate(pressed.value ? 1 : 0, [0, 1], [1, 0.9]);
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
const scale = interpolate(pressed.value ? 1 : 0, [0, 1], [1, 0.9]);
|
||||
|
||||
return {
|
||||
transform: [{ scale: withSpring(scale) }],
|
||||
};
|
||||
});
|
||||
return {
|
||||
transform: [{ scale: withSpring(scale) }],
|
||||
};
|
||||
});
|
||||
|
||||
const handlePressIn = () => {
|
||||
if (!disabled) {
|
||||
pressed.value = true;
|
||||
}
|
||||
};
|
||||
const handlePressIn = () => {
|
||||
if (!disabled) {
|
||||
pressed.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
const handlePressOut = () => {
|
||||
pressed.value = false;
|
||||
};
|
||||
const handlePressOut = () => {
|
||||
pressed.value = false;
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatedTouchableOpacity
|
||||
onPress={onPress}
|
||||
onPressIn={handlePressIn}
|
||||
onPressOut={handlePressOut}
|
||||
disabled={disabled || isCapturing}
|
||||
activeOpacity={0.8}
|
||||
style={animatedStyle}
|
||||
className="items-center justify-center">
|
||||
{/* Outer Ring */}
|
||||
<View
|
||||
className={`
|
||||
return (
|
||||
<AnimatedTouchableOpacity
|
||||
onPress={onPress}
|
||||
onPressIn={handlePressIn}
|
||||
onPressOut={handlePressOut}
|
||||
disabled={disabled || isCapturing}
|
||||
activeOpacity={0.8}
|
||||
style={animatedStyle}
|
||||
className="items-center justify-center"
|
||||
>
|
||||
{/* Outer Ring */}
|
||||
<View
|
||||
className={`
|
||||
h-20 w-20 items-center justify-center rounded-full border-4
|
||||
${disabled || isCapturing ? 'border-gray-400' : 'border-white'}
|
||||
`}>
|
||||
{/* Inner Circle */}
|
||||
<View
|
||||
className={`
|
||||
`}
|
||||
>
|
||||
{/* Inner Circle */}
|
||||
<View
|
||||
className={`
|
||||
h-16 w-16 rounded-full
|
||||
${disabled || isCapturing ? 'bg-gray-400' : 'bg-white'}
|
||||
`}>
|
||||
{isCapturing && (
|
||||
<View className="h-full w-full items-center justify-center rounded-full bg-red-500">
|
||||
<View className="h-8 w-8 rounded bg-white" />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
`}
|
||||
>
|
||||
{isCapturing && (
|
||||
<View className="h-full w-full items-center justify-center rounded-full bg-red-500">
|
||||
<View className="h-8 w-8 rounded bg-white" />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{isCapturing && <Text className="mt-2 text-sm font-medium text-white">Capturing...</Text>}
|
||||
</AnimatedTouchableOpacity>
|
||||
);
|
||||
{isCapturing && <Text className="mt-2 text-sm font-medium text-white">Capturing...</Text>}
|
||||
</AnimatedTouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,60 +4,61 @@ import { Card } from '../ui/Card';
|
|||
import { Button } from '../Button';
|
||||
|
||||
interface PhotoPreviewProps {
|
||||
uri: string;
|
||||
onRetake: () => void;
|
||||
onUse: () => void;
|
||||
isProcessing?: boolean;
|
||||
uri: string;
|
||||
onRetake: () => void;
|
||||
onUse: () => void;
|
||||
isProcessing?: boolean;
|
||||
}
|
||||
|
||||
export const PhotoPreview: React.FC<PhotoPreviewProps> = ({
|
||||
uri,
|
||||
onRetake,
|
||||
onUse,
|
||||
isProcessing = false,
|
||||
uri,
|
||||
onRetake,
|
||||
onUse,
|
||||
isProcessing = false,
|
||||
}) => {
|
||||
return (
|
||||
<View className="flex-1 bg-black">
|
||||
{/* Photo */}
|
||||
<View className="flex-1 justify-center">
|
||||
<Image source={{ uri }} className="h-full w-full" resizeMode="contain" />
|
||||
</View>
|
||||
return (
|
||||
<View className="flex-1 bg-black">
|
||||
{/* Photo */}
|
||||
<View className="flex-1 justify-center">
|
||||
<Image source={{ uri }} className="h-full w-full" resizeMode="contain" />
|
||||
</View>
|
||||
|
||||
{/* Controls */}
|
||||
<View className="absolute bottom-0 left-0 right-0 bg-black/50 p-6">
|
||||
<Card className="bg-white/90 backdrop-blur">
|
||||
<View className="space-y-4">
|
||||
<Text className="text-center text-lg font-semibold text-gray-900">
|
||||
How does this look?
|
||||
</Text>
|
||||
{/* Controls */}
|
||||
<View className="absolute bottom-0 left-0 right-0 bg-black/50 p-6">
|
||||
<Card className="bg-white/90 backdrop-blur">
|
||||
<View className="space-y-4">
|
||||
<Text className="text-center text-lg font-semibold text-gray-900">
|
||||
How does this look?
|
||||
</Text>
|
||||
|
||||
<Text className="text-center text-sm text-gray-600">
|
||||
Make sure your food is clearly visible and well-lit for the best analysis results.
|
||||
</Text>
|
||||
<Text className="text-center text-sm text-gray-600">
|
||||
Make sure your food is clearly visible and well-lit for the best analysis results.
|
||||
</Text>
|
||||
|
||||
<View className="flex-row space-x-3">
|
||||
<TouchableOpacity
|
||||
onPress={onRetake}
|
||||
disabled={isProcessing}
|
||||
className={`
|
||||
<View className="flex-row space-x-3">
|
||||
<TouchableOpacity
|
||||
onPress={onRetake}
|
||||
disabled={isProcessing}
|
||||
className={`
|
||||
flex-1 items-center rounded-lg border-2 px-4 py-3
|
||||
${isProcessing ? 'border-gray-300 bg-gray-100' : 'border-gray-300 bg-white'}
|
||||
`}>
|
||||
<Text className={`font-medium ${isProcessing ? 'text-gray-400' : 'text-gray-700'}`}>
|
||||
Retake
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
`}
|
||||
>
|
||||
<Text className={`font-medium ${isProcessing ? 'text-gray-400' : 'text-gray-700'}`}>
|
||||
Retake
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<Button
|
||||
title={isProcessing ? 'Analyzing...' : 'Use Photo'}
|
||||
onPress={onUse}
|
||||
disabled={isProcessing}
|
||||
className="flex-1"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</Card>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
<Button
|
||||
title={isProcessing ? 'Analyzing...' : 'Use Photo'}
|
||||
onPress={onUse}
|
||||
disabled={isProcessing}
|
||||
className="flex-1"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</Card>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,84 +4,84 @@ import { Ionicons } from '@expo/vector-icons';
|
|||
import { Button } from '../Button';
|
||||
|
||||
interface LocationPermissionModalProps {
|
||||
visible: boolean;
|
||||
onAllow: () => void;
|
||||
onDeny: () => void;
|
||||
visible: boolean;
|
||||
onAllow: () => void;
|
||||
onDeny: () => void;
|
||||
}
|
||||
|
||||
export const LocationPermissionModal: React.FC<LocationPermissionModalProps> = ({
|
||||
visible,
|
||||
onAllow,
|
||||
onDeny,
|
||||
visible,
|
||||
onAllow,
|
||||
onDeny,
|
||||
}) => {
|
||||
return (
|
||||
<Modal visible={visible} animationType="slide" presentationStyle="pageSheet" transparent={true}>
|
||||
<View className="flex-1 justify-end bg-black/50">
|
||||
<View className="rounded-t-3xl bg-white p-6 pb-8">
|
||||
{/* Icon */}
|
||||
<View className="mb-4 items-center">
|
||||
<View className="h-20 w-20 items-center justify-center rounded-full bg-blue-100">
|
||||
<Ionicons name="location" size={40} color="#3b82f6" />
|
||||
</View>
|
||||
</View>
|
||||
return (
|
||||
<Modal visible={visible} animationType="slide" presentationStyle="pageSheet" transparent={true}>
|
||||
<View className="flex-1 justify-end bg-black/50">
|
||||
<View className="rounded-t-3xl bg-white p-6 pb-8">
|
||||
{/* Icon */}
|
||||
<View className="mb-4 items-center">
|
||||
<View className="h-20 w-20 items-center justify-center rounded-full bg-blue-100">
|
||||
<Ionicons name="location" size={40} color="#3b82f6" />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Title */}
|
||||
<Text className="mb-3 text-center text-2xl font-bold text-gray-900">
|
||||
Standort speichern?
|
||||
</Text>
|
||||
{/* Title */}
|
||||
<Text className="mb-3 text-center text-2xl font-bold text-gray-900">
|
||||
Standort speichern?
|
||||
</Text>
|
||||
|
||||
{/* Description */}
|
||||
<Text className="mb-6 text-center text-base text-gray-600">
|
||||
Nutriphi kann den Standort deiner Mahlzeiten speichern, um dir personalisierte Einblicke
|
||||
zu geben:
|
||||
</Text>
|
||||
{/* Description */}
|
||||
<Text className="mb-6 text-center text-base text-gray-600">
|
||||
Nutriphi kann den Standort deiner Mahlzeiten speichern, um dir personalisierte Einblicke
|
||||
zu geben:
|
||||
</Text>
|
||||
|
||||
{/* Benefits */}
|
||||
<View className="mb-6 space-y-3">
|
||||
<View className="flex-row items-center">
|
||||
<Ionicons name="restaurant-outline" size={20} color="#6b7280" />
|
||||
<Text className="ml-3 flex-1 text-sm text-gray-700">
|
||||
Automatische Restaurant-Erkennung
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex-row items-center">
|
||||
<Ionicons name="stats-chart-outline" size={20} color="#6b7280" />
|
||||
<Text className="ml-3 flex-1 text-sm text-gray-700">
|
||||
Analyse wo du am gesündesten isst
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex-row items-center">
|
||||
<Ionicons name="map-outline" size={20} color="#6b7280" />
|
||||
<Text className="ml-3 flex-1 text-sm text-gray-700">
|
||||
Ernährungstracking auf Reisen
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
{/* Benefits */}
|
||||
<View className="mb-6 space-y-3">
|
||||
<View className="flex-row items-center">
|
||||
<Ionicons name="restaurant-outline" size={20} color="#6b7280" />
|
||||
<Text className="ml-3 flex-1 text-sm text-gray-700">
|
||||
Automatische Restaurant-Erkennung
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex-row items-center">
|
||||
<Ionicons name="stats-chart-outline" size={20} color="#6b7280" />
|
||||
<Text className="ml-3 flex-1 text-sm text-gray-700">
|
||||
Analyse wo du am gesündesten isst
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex-row items-center">
|
||||
<Ionicons name="map-outline" size={20} color="#6b7280" />
|
||||
<Text className="ml-3 flex-1 text-sm text-gray-700">
|
||||
Ernährungstracking auf Reisen
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Privacy Note */}
|
||||
<View className="mb-6 rounded-lg bg-gray-50 p-3">
|
||||
<Text className="text-xs text-gray-600">
|
||||
<Text className="font-semibold">🔒 Deine Privatsphäre ist uns wichtig:</Text>
|
||||
{'\n'}
|
||||
Standortdaten werden nur lokal auf deinem Gerät gespeichert und können jederzeit in
|
||||
den Einstellungen deaktiviert werden.
|
||||
</Text>
|
||||
</View>
|
||||
{/* Privacy Note */}
|
||||
<View className="mb-6 rounded-lg bg-gray-50 p-3">
|
||||
<Text className="text-xs text-gray-600">
|
||||
<Text className="font-semibold">🔒 Deine Privatsphäre ist uns wichtig:</Text>
|
||||
{'\n'}
|
||||
Standortdaten werden nur lokal auf deinem Gerät gespeichert und können jederzeit in
|
||||
den Einstellungen deaktiviert werden.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Buttons */}
|
||||
<View className="space-y-3">
|
||||
<Button title="Standort erlauben" onPress={onAllow} className="w-full bg-blue-600" />
|
||||
<TouchableOpacity onPress={onDeny} className="py-3">
|
||||
<Text className="text-center text-base text-gray-600">Nicht jetzt</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
{/* Buttons */}
|
||||
<View className="space-y-3">
|
||||
<Button title="Standort erlauben" onPress={onAllow} className="w-full bg-blue-600" />
|
||||
<TouchableOpacity onPress={onDeny} className="py-3">
|
||||
<Text className="text-center text-base text-gray-600">Nicht jetzt</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Settings hint */}
|
||||
<Text className="mt-4 text-center text-xs text-gray-500">
|
||||
Du kannst diese Einstellung jederzeit in den App-Einstellungen ändern.
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
{/* Settings hint */}
|
||||
<Text className="mt-4 text-center text-xs text-gray-500">
|
||||
Du kannst diese Einstellung jederzeit in den App-Einstellungen ändern.
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,100 +4,100 @@ import { Ionicons } from '@expo/vector-icons';
|
|||
import { LoadingSpinner } from '../ui/LoadingSpinner';
|
||||
|
||||
interface AnalysisStatusIndicatorProps {
|
||||
status: 'pending' | 'completed' | 'failed' | 'manual';
|
||||
mini?: boolean;
|
||||
status: 'pending' | 'completed' | 'failed' | 'manual';
|
||||
mini?: boolean;
|
||||
}
|
||||
|
||||
export const AnalysisStatusIndicator: React.FC<AnalysisStatusIndicatorProps> = ({
|
||||
status,
|
||||
mini = false,
|
||||
status,
|
||||
mini = false,
|
||||
}) => {
|
||||
const getStatusConfig = () => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return {
|
||||
bgColor: 'bg-yellow-100',
|
||||
textColor: 'text-yellow-800',
|
||||
icon: null,
|
||||
text: 'Wird analysiert...',
|
||||
showSpinner: true,
|
||||
};
|
||||
case 'completed':
|
||||
return {
|
||||
bgColor: 'bg-green-100',
|
||||
textColor: 'text-green-800',
|
||||
icon: 'checkmark-circle' as const,
|
||||
text: 'Analysiert',
|
||||
showSpinner: false,
|
||||
};
|
||||
case 'failed':
|
||||
return {
|
||||
bgColor: 'bg-red-100',
|
||||
textColor: 'text-red-800',
|
||||
icon: 'alert-circle' as const,
|
||||
text: 'Analyse fehlgeschlagen',
|
||||
showSpinner: false,
|
||||
};
|
||||
case 'manual':
|
||||
return {
|
||||
bgColor: 'bg-gray-100',
|
||||
textColor: 'text-gray-800',
|
||||
icon: 'create-outline' as const,
|
||||
text: 'Manuell',
|
||||
showSpinner: false,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
bgColor: 'bg-gray-100',
|
||||
textColor: 'text-gray-800',
|
||||
icon: 'help-circle-outline' as const,
|
||||
text: 'Unbekannt',
|
||||
showSpinner: false,
|
||||
};
|
||||
}
|
||||
};
|
||||
const getStatusConfig = () => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return {
|
||||
bgColor: 'bg-yellow-100',
|
||||
textColor: 'text-yellow-800',
|
||||
icon: null,
|
||||
text: 'Wird analysiert...',
|
||||
showSpinner: true,
|
||||
};
|
||||
case 'completed':
|
||||
return {
|
||||
bgColor: 'bg-green-100',
|
||||
textColor: 'text-green-800',
|
||||
icon: 'checkmark-circle' as const,
|
||||
text: 'Analysiert',
|
||||
showSpinner: false,
|
||||
};
|
||||
case 'failed':
|
||||
return {
|
||||
bgColor: 'bg-red-100',
|
||||
textColor: 'text-red-800',
|
||||
icon: 'alert-circle' as const,
|
||||
text: 'Analyse fehlgeschlagen',
|
||||
showSpinner: false,
|
||||
};
|
||||
case 'manual':
|
||||
return {
|
||||
bgColor: 'bg-gray-100',
|
||||
textColor: 'text-gray-800',
|
||||
icon: 'create-outline' as const,
|
||||
text: 'Manuell',
|
||||
showSpinner: false,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
bgColor: 'bg-gray-100',
|
||||
textColor: 'text-gray-800',
|
||||
icon: 'help-circle-outline' as const,
|
||||
text: 'Unbekannt',
|
||||
showSpinner: false,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const config = getStatusConfig();
|
||||
const config = getStatusConfig();
|
||||
|
||||
if (mini) {
|
||||
return (
|
||||
<View className={`rounded-full px-2 py-1 ${config.bgColor}`}>
|
||||
<View className="flex-row items-center">
|
||||
{config.showSpinner ? (
|
||||
<LoadingSpinner size={12} color="#ca8a04" />
|
||||
) : (
|
||||
config.icon && <Ionicons name={config.icon} size={12} color="#ca8a04" />
|
||||
)}
|
||||
<Text className={`ml-1 text-xs font-medium ${config.textColor}`}>{config.text}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (mini) {
|
||||
return (
|
||||
<View className={`rounded-full px-2 py-1 ${config.bgColor}`}>
|
||||
<View className="flex-row items-center">
|
||||
{config.showSpinner ? (
|
||||
<LoadingSpinner size={12} color="#ca8a04" />
|
||||
) : (
|
||||
config.icon && <Ionicons name={config.icon} size={12} color="#ca8a04" />
|
||||
)}
|
||||
<Text className={`ml-1 text-xs font-medium ${config.textColor}`}>{config.text}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View className={`rounded-lg p-3 ${config.bgColor}`}>
|
||||
<View className="flex-row items-center">
|
||||
{config.showSpinner ? (
|
||||
<LoadingSpinner size={20} color="#ca8a04" />
|
||||
) : (
|
||||
config.icon && (
|
||||
<Ionicons
|
||||
name={config.icon}
|
||||
size={20}
|
||||
color={
|
||||
config.textColor === 'text-green-800'
|
||||
? '#166534'
|
||||
: config.textColor === 'text-red-800'
|
||||
? '#991b1b'
|
||||
: config.textColor === 'text-yellow-800'
|
||||
? '#854d0e'
|
||||
: '#1f2937'
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<Text className={`ml-2 text-sm font-medium ${config.textColor}`}>{config.text}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
return (
|
||||
<View className={`rounded-lg p-3 ${config.bgColor}`}>
|
||||
<View className="flex-row items-center">
|
||||
{config.showSpinner ? (
|
||||
<LoadingSpinner size={20} color="#ca8a04" />
|
||||
) : (
|
||||
config.icon && (
|
||||
<Ionicons
|
||||
name={config.icon}
|
||||
size={20}
|
||||
color={
|
||||
config.textColor === 'text-green-800'
|
||||
? '#166534'
|
||||
: config.textColor === 'text-red-800'
|
||||
? '#991b1b'
|
||||
: config.textColor === 'text-yellow-800'
|
||||
? '#854d0e'
|
||||
: '#1f2937'
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<Text className={`ml-2 text-sm font-medium ${config.textColor}`}>{config.text}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,154 +1,157 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
ScrollView,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
Modal,
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
ScrollView,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { MealWithItems } from '../../types/Database';
|
||||
import { useMealStore } from '../../store/MealStore';
|
||||
|
||||
interface EditMealModalProps {
|
||||
meal: MealWithItems | null;
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
meal: MealWithItems | null;
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const EditMealModal: React.FC<EditMealModalProps> = ({ meal, visible, onClose }) => {
|
||||
const { updateMeal } = useMealStore();
|
||||
const [notes, setNotes] = useState(meal?.user_notes || '');
|
||||
const [rating, setRating] = useState(meal?.user_rating || 0);
|
||||
const [location, setLocation] = useState(meal?.location || '');
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const { updateMeal } = useMealStore();
|
||||
const [notes, setNotes] = useState(meal?.user_notes || '');
|
||||
const [rating, setRating] = useState(meal?.user_rating || 0);
|
||||
const [location, setLocation] = useState(meal?.location || '');
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (meal) {
|
||||
setNotes(meal.user_notes || '');
|
||||
setRating(meal.user_rating || 0);
|
||||
setLocation(meal.location || '');
|
||||
}
|
||||
}, [meal]);
|
||||
React.useEffect(() => {
|
||||
if (meal) {
|
||||
setNotes(meal.user_notes || '');
|
||||
setRating(meal.user_rating || 0);
|
||||
setLocation(meal.location || '');
|
||||
}
|
||||
}, [meal]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!meal) return;
|
||||
const handleSave = async () => {
|
||||
if (!meal) return;
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await updateMeal(meal.id, {
|
||||
user_notes: notes.trim() || null,
|
||||
user_rating: rating || null,
|
||||
location: location.trim() || null,
|
||||
});
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Failed to update meal:', error);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await updateMeal(meal.id, {
|
||||
user_notes: notes.trim() || null,
|
||||
user_rating: rating || null,
|
||||
location: location.trim() || null,
|
||||
});
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Failed to update meal:', error);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderStars = () => {
|
||||
return (
|
||||
<View className="flex-row justify-center space-x-2">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<TouchableOpacity key={star} onPress={() => setRating(star)} className="p-2">
|
||||
<Ionicons
|
||||
name={star <= rating ? 'star' : 'star-outline'}
|
||||
size={32}
|
||||
color={star <= rating ? '#fbbf24' : '#d1d5db'}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
const renderStars = () => {
|
||||
return (
|
||||
<View className="flex-row justify-center space-x-2">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<TouchableOpacity key={star} onPress={() => setRating(star)} className="p-2">
|
||||
<Ionicons
|
||||
name={star <= rating ? 'star' : 'star-outline'}
|
||||
size={32}
|
||||
color={star <= rating ? '#fbbf24' : '#d1d5db'}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
if (!meal) return null;
|
||||
if (!meal) return null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
animationType="slide"
|
||||
presentationStyle="pageSheet"
|
||||
onRequestClose={onClose}>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
className="flex-1 bg-white">
|
||||
{/* Header */}
|
||||
<View className="flex-row items-center justify-between border-b border-gray-200 p-4">
|
||||
<TouchableOpacity onPress={onClose} className="p-2">
|
||||
<Text className="text-base text-blue-600">Abbrechen</Text>
|
||||
</TouchableOpacity>
|
||||
<Text className="text-lg font-semibold">Mahlzeit bearbeiten</Text>
|
||||
<TouchableOpacity onPress={handleSave} disabled={isSaving} className="p-2">
|
||||
<Text
|
||||
className={`text-base font-semibold ${isSaving ? 'text-gray-400' : 'text-blue-600'}`}>
|
||||
{isSaving ? 'Speichert...' : 'Fertig'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
animationType="slide"
|
||||
presentationStyle="pageSheet"
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
className="flex-1 bg-white"
|
||||
>
|
||||
{/* Header */}
|
||||
<View className="flex-row items-center justify-between border-b border-gray-200 p-4">
|
||||
<TouchableOpacity onPress={onClose} className="p-2">
|
||||
<Text className="text-base text-blue-600">Abbrechen</Text>
|
||||
</TouchableOpacity>
|
||||
<Text className="text-lg font-semibold">Mahlzeit bearbeiten</Text>
|
||||
<TouchableOpacity onPress={handleSave} disabled={isSaving} className="p-2">
|
||||
<Text
|
||||
className={`text-base font-semibold ${isSaving ? 'text-gray-400' : 'text-blue-600'}`}
|
||||
>
|
||||
{isSaving ? 'Speichert...' : 'Fertig'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Content */}
|
||||
<ScrollView className="flex-1 p-4">
|
||||
{/* Rating */}
|
||||
<View className="mb-6">
|
||||
<Text className="mb-3 text-base font-semibold text-gray-900">Bewertung</Text>
|
||||
{renderStars()}
|
||||
{rating > 0 && (
|
||||
<TouchableOpacity onPress={() => setRating(0)} className="mt-2 self-center">
|
||||
<Text className="text-sm text-gray-500">Bewertung entfernen</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
{/* Content */}
|
||||
<ScrollView className="flex-1 p-4">
|
||||
{/* Rating */}
|
||||
<View className="mb-6">
|
||||
<Text className="mb-3 text-base font-semibold text-gray-900">Bewertung</Text>
|
||||
{renderStars()}
|
||||
{rating > 0 && (
|
||||
<TouchableOpacity onPress={() => setRating(0)} className="mt-2 self-center">
|
||||
<Text className="text-sm text-gray-500">Bewertung entfernen</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Location */}
|
||||
<View className="mb-6">
|
||||
<Text className="mb-2 text-base font-semibold text-gray-900">Ort</Text>
|
||||
<TextInput
|
||||
value={location}
|
||||
onChangeText={setLocation}
|
||||
placeholder="z.B. Restaurant, Zuhause, Büro..."
|
||||
placeholderTextColor="#9ca3af"
|
||||
className="rounded-lg border border-gray-300 p-3 text-base"
|
||||
returnKeyType="done"
|
||||
/>
|
||||
</View>
|
||||
{/* Location */}
|
||||
<View className="mb-6">
|
||||
<Text className="mb-2 text-base font-semibold text-gray-900">Ort</Text>
|
||||
<TextInput
|
||||
value={location}
|
||||
onChangeText={setLocation}
|
||||
placeholder="z.B. Restaurant, Zuhause, Büro..."
|
||||
placeholderTextColor="#9ca3af"
|
||||
className="rounded-lg border border-gray-300 p-3 text-base"
|
||||
returnKeyType="done"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Notes */}
|
||||
<View className="mb-6">
|
||||
<Text className="mb-2 text-base font-semibold text-gray-900">Notizen</Text>
|
||||
<TextInput
|
||||
value={notes}
|
||||
onChangeText={setNotes}
|
||||
placeholder="Füge Notizen zu dieser Mahlzeit hinzu..."
|
||||
placeholderTextColor="#9ca3af"
|
||||
multiline
|
||||
numberOfLines={4}
|
||||
textAlignVertical="top"
|
||||
className="rounded-lg border border-gray-300 p-3 text-base"
|
||||
style={{ minHeight: 100 }}
|
||||
/>
|
||||
</View>
|
||||
{/* Notes */}
|
||||
<View className="mb-6">
|
||||
<Text className="mb-2 text-base font-semibold text-gray-900">Notizen</Text>
|
||||
<TextInput
|
||||
value={notes}
|
||||
onChangeText={setNotes}
|
||||
placeholder="Füge Notizen zu dieser Mahlzeit hinzu..."
|
||||
placeholderTextColor="#9ca3af"
|
||||
multiline
|
||||
numberOfLines={4}
|
||||
textAlignVertical="top"
|
||||
className="rounded-lg border border-gray-300 p-3 text-base"
|
||||
style={{ minHeight: 100 }}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Meal Info */}
|
||||
<View className="rounded-lg bg-gray-50 p-4">
|
||||
<Text className="mb-2 text-sm font-medium text-gray-600">Mahlzeit-Info</Text>
|
||||
<Text className="text-sm text-gray-600">
|
||||
{meal.food_items?.map((item) => item.name).join(', ') || 'Keine Lebensmittel erkannt'}
|
||||
</Text>
|
||||
{meal.total_calories && (
|
||||
<Text className="mt-1 text-sm text-gray-600">
|
||||
{Math.round(meal.total_calories)} kcal
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
</Modal>
|
||||
);
|
||||
{/* Meal Info */}
|
||||
<View className="rounded-lg bg-gray-50 p-4">
|
||||
<Text className="mb-2 text-sm font-medium text-gray-600">Mahlzeit-Info</Text>
|
||||
<Text className="text-sm text-gray-600">
|
||||
{meal.food_items?.map((item) => item.name).join(', ') || 'Keine Lebensmittel erkannt'}
|
||||
</Text>
|
||||
{meal.total_calories && (
|
||||
<Text className="mt-1 text-sm text-gray-600">
|
||||
{Math.round(meal.total_calories)} kcal
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,158 +4,159 @@ import { Ionicons } from '@expo/vector-icons';
|
|||
import { FoodItem } from '@/types/Database';
|
||||
|
||||
interface FoodItemCardProps {
|
||||
foodItem: FoodItem;
|
||||
categoryColor?: string;
|
||||
onPress?: () => void;
|
||||
showDetails?: boolean;
|
||||
foodItem: FoodItem;
|
||||
categoryColor?: string;
|
||||
onPress?: () => void;
|
||||
showDetails?: boolean;
|
||||
}
|
||||
|
||||
export const FoodItemCard: React.FC<FoodItemCardProps> = (props) => {
|
||||
const {
|
||||
foodItem,
|
||||
categoryColor = 'border-gray-200 bg-gray-50',
|
||||
onPress,
|
||||
showDetails = true,
|
||||
} = props;
|
||||
const formatValue = (value?: number, unit: string = 'g') => {
|
||||
if (value === undefined || value === null) return '--';
|
||||
return `${Math.round(value)}${unit}`;
|
||||
};
|
||||
const {
|
||||
foodItem,
|
||||
categoryColor = 'border-gray-200 bg-gray-50',
|
||||
onPress,
|
||||
showDetails = true,
|
||||
} = props;
|
||||
const formatValue = (value?: number, unit: string = 'g') => {
|
||||
if (value === undefined || value === null) return '--';
|
||||
return `${Math.round(value)}${unit}`;
|
||||
};
|
||||
|
||||
const getConfidenceColor = (confidence?: number) => {
|
||||
if (!confidence) return 'text-gray-400';
|
||||
if (confidence >= 0.8) return 'text-green-600';
|
||||
if (confidence >= 0.6) return 'text-yellow-600';
|
||||
return 'text-red-600';
|
||||
};
|
||||
const getConfidenceColor = (confidence?: number) => {
|
||||
if (!confidence) return 'text-gray-400';
|
||||
if (confidence >= 0.8) return 'text-green-600';
|
||||
if (confidence >= 0.6) return 'text-yellow-600';
|
||||
return 'text-red-600';
|
||||
};
|
||||
|
||||
const getConfidenceIcon = (confidence?: number) => {
|
||||
if (!confidence) return 'help-outline';
|
||||
if (confidence >= 0.8) return 'checkmark-circle-outline';
|
||||
if (confidence >= 0.6) return 'warning-outline';
|
||||
return 'alert-circle-outline';
|
||||
};
|
||||
const getConfidenceIcon = (confidence?: number) => {
|
||||
if (!confidence) return 'help-outline';
|
||||
if (confidence >= 0.8) return 'checkmark-circle-outline';
|
||||
if (confidence >= 0.6) return 'warning-outline';
|
||||
return 'alert-circle-outline';
|
||||
};
|
||||
|
||||
const renderNutritionValue = (
|
||||
label: string,
|
||||
value?: number,
|
||||
unit: string = 'g',
|
||||
color: string = 'text-gray-700'
|
||||
) => {
|
||||
if (value === undefined || value === null) return null;
|
||||
const renderNutritionValue = (
|
||||
label: string,
|
||||
value?: number,
|
||||
unit: string = 'g',
|
||||
color: string = 'text-gray-700'
|
||||
) => {
|
||||
if (value === undefined || value === null) return null;
|
||||
|
||||
return (
|
||||
<View className="items-center">
|
||||
<Text className={`text-sm font-medium ${color}`}>{formatValue(value, unit)}</Text>
|
||||
<Text className="text-xs uppercase tracking-wide text-gray-500">{label}</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<View className="items-center">
|
||||
<Text className={`text-sm font-medium ${color}`}>{formatValue(value, unit)}</Text>
|
||||
<Text className="text-xs uppercase tracking-wide text-gray-500">{label}</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const CardComponent = onPress ? TouchableOpacity : View;
|
||||
const CardComponent = onPress ? TouchableOpacity : View;
|
||||
|
||||
return (
|
||||
<CardComponent
|
||||
onPress={onPress}
|
||||
activeOpacity={onPress ? 0.7 : 1}
|
||||
className={`rounded-lg border p-4 ${categoryColor}`}>
|
||||
{/* Header */}
|
||||
<View className="mb-3 flex-row items-start justify-between">
|
||||
<View className="mr-3 flex-1">
|
||||
<Text className="mb-1 text-base font-semibold text-gray-900">{foodItem.name}</Text>
|
||||
{foodItem.portion_size && (
|
||||
<Text className="text-sm text-gray-600">{foodItem.portion_size}</Text>
|
||||
)}
|
||||
</View>
|
||||
return (
|
||||
<CardComponent
|
||||
onPress={onPress}
|
||||
activeOpacity={onPress ? 0.7 : 1}
|
||||
className={`rounded-lg border p-4 ${categoryColor}`}
|
||||
>
|
||||
{/* Header */}
|
||||
<View className="mb-3 flex-row items-start justify-between">
|
||||
<View className="mr-3 flex-1">
|
||||
<Text className="mb-1 text-base font-semibold text-gray-900">{foodItem.name}</Text>
|
||||
{foodItem.portion_size && (
|
||||
<Text className="text-sm text-gray-600">{foodItem.portion_size}</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Confidence Indicator */}
|
||||
{foodItem.confidence && (
|
||||
<View className="flex-row items-center">
|
||||
<Ionicons
|
||||
name={getConfidenceIcon(foodItem.confidence)}
|
||||
size={16}
|
||||
color={
|
||||
getConfidenceColor(foodItem.confidence) === 'text-green-600'
|
||||
? '#16a34a'
|
||||
: getConfidenceColor(foodItem.confidence) === 'text-yellow-600'
|
||||
? '#ca8a04'
|
||||
: '#dc2626'
|
||||
}
|
||||
/>
|
||||
<Text className={`ml-1 text-xs ${getConfidenceColor(foodItem.confidence)}`}>
|
||||
{Math.round(foodItem.confidence * 100)}%
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
{/* Confidence Indicator */}
|
||||
{foodItem.confidence && (
|
||||
<View className="flex-row items-center">
|
||||
<Ionicons
|
||||
name={getConfidenceIcon(foodItem.confidence)}
|
||||
size={16}
|
||||
color={
|
||||
getConfidenceColor(foodItem.confidence) === 'text-green-600'
|
||||
? '#16a34a'
|
||||
: getConfidenceColor(foodItem.confidence) === 'text-yellow-600'
|
||||
? '#ca8a04'
|
||||
: '#dc2626'
|
||||
}
|
||||
/>
|
||||
<Text className={`ml-1 text-xs ${getConfidenceColor(foodItem.confidence)}`}>
|
||||
{Math.round(foodItem.confidence * 100)}%
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Nutrition Information */}
|
||||
{showDetails && (
|
||||
<View className="space-y-3">
|
||||
{/* Main Calories */}
|
||||
{foodItem.calories && (
|
||||
<View className="rounded-lg border border-gray-100 bg-white p-3">
|
||||
<Text className="text-center text-lg font-bold text-gray-900">
|
||||
{formatValue(foodItem.calories, ' kcal')}
|
||||
</Text>
|
||||
<Text className="text-center text-xs uppercase tracking-wide text-gray-500">
|
||||
Kalorien
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{/* Nutrition Information */}
|
||||
{showDetails && (
|
||||
<View className="space-y-3">
|
||||
{/* Main Calories */}
|
||||
{foodItem.calories && (
|
||||
<View className="rounded-lg border border-gray-100 bg-white p-3">
|
||||
<Text className="text-center text-lg font-bold text-gray-900">
|
||||
{formatValue(foodItem.calories, ' kcal')}
|
||||
</Text>
|
||||
<Text className="text-center text-xs uppercase tracking-wide text-gray-500">
|
||||
Kalorien
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Macronutrients */}
|
||||
{(foodItem.protein || foodItem.carbs || foodItem.fat) && (
|
||||
<View className="flex-row justify-between">
|
||||
{renderNutritionValue('Protein', foodItem.protein, 'g', 'text-blue-600')}
|
||||
{renderNutritionValue('Kohlenhydrate', foodItem.carbs, 'g', 'text-green-600')}
|
||||
{renderNutritionValue('Fett', foodItem.fat, 'g', 'text-orange-600')}
|
||||
</View>
|
||||
)}
|
||||
{/* Macronutrients */}
|
||||
{(foodItem.protein || foodItem.carbs || foodItem.fat) && (
|
||||
<View className="flex-row justify-between">
|
||||
{renderNutritionValue('Protein', foodItem.protein, 'g', 'text-blue-600')}
|
||||
{renderNutritionValue('Kohlenhydrate', foodItem.carbs, 'g', 'text-green-600')}
|
||||
{renderNutritionValue('Fett', foodItem.fat, 'g', 'text-orange-600')}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Additional nutrients */}
|
||||
{(foodItem.fiber || foodItem.sugar) && (
|
||||
<View className="flex-row justify-between">
|
||||
{renderNutritionValue('Ballaststoffe', foodItem.fiber, 'g', 'text-purple-600')}
|
||||
{renderNutritionValue('Zucker', foodItem.sugar, 'g', 'text-pink-600')}
|
||||
<View /> {/* Spacer for alignment */}
|
||||
</View>
|
||||
)}
|
||||
{/* Additional nutrients */}
|
||||
{(foodItem.fiber || foodItem.sugar) && (
|
||||
<View className="flex-row justify-between">
|
||||
{renderNutritionValue('Ballaststoffe', foodItem.fiber, 'g', 'text-purple-600')}
|
||||
{renderNutritionValue('Zucker', foodItem.sugar, 'g', 'text-pink-600')}
|
||||
<View /> {/* Spacer for alignment */}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Food Properties */}
|
||||
<View className="flex-row flex-wrap gap-2">
|
||||
{Boolean(foodItem.is_organic) && (
|
||||
<View className="rounded-full bg-green-100 px-2 py-1">
|
||||
<Text className="text-xs font-medium text-green-800">🌱 Bio</Text>
|
||||
</View>
|
||||
)}
|
||||
{Boolean(foodItem.is_processed) && (
|
||||
<View className="rounded-full bg-orange-100 px-2 py-1">
|
||||
<Text className="text-xs font-medium text-orange-800">📦 Verarbeitet</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
{/* Food Properties */}
|
||||
<View className="flex-row flex-wrap gap-2">
|
||||
{Boolean(foodItem.is_organic) && (
|
||||
<View className="rounded-full bg-green-100 px-2 py-1">
|
||||
<Text className="text-xs font-medium text-green-800">🌱 Bio</Text>
|
||||
</View>
|
||||
)}
|
||||
{Boolean(foodItem.is_processed) && (
|
||||
<View className="rounded-full bg-orange-100 px-2 py-1">
|
||||
<Text className="text-xs font-medium text-orange-800">📦 Verarbeitet</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Allergens */}
|
||||
{foodItem.allergens && (
|
||||
<View>
|
||||
<Text className="mb-1 text-xs text-gray-600">Allergene:</Text>
|
||||
<Text className="text-xs text-red-600">
|
||||
{(() => {
|
||||
try {
|
||||
const allergens = JSON.parse(foodItem.allergens);
|
||||
return Array.isArray(allergens) && allergens.length > 0
|
||||
? allergens.join(', ')
|
||||
: 'Keine';
|
||||
} catch {
|
||||
return 'Keine';
|
||||
}
|
||||
})()}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</CardComponent>
|
||||
);
|
||||
{/* Allergens */}
|
||||
{foodItem.allergens && (
|
||||
<View>
|
||||
<Text className="mb-1 text-xs text-gray-600">Allergene:</Text>
|
||||
<Text className="text-xs text-red-600">
|
||||
{(() => {
|
||||
try {
|
||||
const allergens = JSON.parse(foodItem.allergens);
|
||||
return Array.isArray(allergens) && allergens.length > 0
|
||||
? allergens.join(', ')
|
||||
: 'Keine';
|
||||
} catch {
|
||||
return 'Keine';
|
||||
}
|
||||
})()}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</CardComponent>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,159 +4,159 @@ import { FoodItem } from '@/types/Database';
|
|||
import { FoodItemCard } from './FoodItemCard';
|
||||
|
||||
interface FoodItemListProps {
|
||||
foodItems: FoodItem[];
|
||||
title?: string;
|
||||
showTitle?: boolean;
|
||||
foodItems: FoodItem[];
|
||||
title?: string;
|
||||
showTitle?: boolean;
|
||||
}
|
||||
|
||||
export const FoodItemList: React.FC<FoodItemListProps> = ({
|
||||
foodItems,
|
||||
title = 'Erkannte Lebensmittel',
|
||||
showTitle = true,
|
||||
foodItems,
|
||||
title = 'Erkannte Lebensmittel',
|
||||
showTitle = true,
|
||||
}) => {
|
||||
if (!foodItems || foodItems.length === 0) {
|
||||
return (
|
||||
<View className="py-4">
|
||||
{showTitle && <Text className="mb-3 text-lg font-semibold text-gray-900">{title}</Text>}
|
||||
<View className="items-center rounded-lg bg-gray-50 p-6">
|
||||
<Text className="mb-2 text-4xl">🍽️</Text>
|
||||
<Text className="text-center text-gray-600">Keine Lebensmittel erkannt</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (!foodItems || foodItems.length === 0) {
|
||||
return (
|
||||
<View className="py-4">
|
||||
{showTitle && <Text className="mb-3 text-lg font-semibold text-gray-900">{title}</Text>}
|
||||
<View className="items-center rounded-lg bg-gray-50 p-6">
|
||||
<Text className="mb-2 text-4xl">🍽️</Text>
|
||||
<Text className="text-center text-gray-600">Keine Lebensmittel erkannt</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const getCategoryIcon = (category: string) => {
|
||||
switch (category) {
|
||||
case 'protein':
|
||||
return '🥩';
|
||||
case 'vegetable':
|
||||
return '🥕';
|
||||
case 'grain':
|
||||
return '🌾';
|
||||
case 'fruit':
|
||||
return '🍎';
|
||||
case 'dairy':
|
||||
return '🥛';
|
||||
case 'fat':
|
||||
return '🥑';
|
||||
case 'processed':
|
||||
return '📦';
|
||||
case 'beverage':
|
||||
return '🥤';
|
||||
default:
|
||||
return '🍽️';
|
||||
}
|
||||
};
|
||||
const getCategoryIcon = (category: string) => {
|
||||
switch (category) {
|
||||
case 'protein':
|
||||
return '🥩';
|
||||
case 'vegetable':
|
||||
return '🥕';
|
||||
case 'grain':
|
||||
return '🌾';
|
||||
case 'fruit':
|
||||
return '🍎';
|
||||
case 'dairy':
|
||||
return '🥛';
|
||||
case 'fat':
|
||||
return '🥑';
|
||||
case 'processed':
|
||||
return '📦';
|
||||
case 'beverage':
|
||||
return '🥤';
|
||||
default:
|
||||
return '🍽️';
|
||||
}
|
||||
};
|
||||
|
||||
const getCategoryColor = (category: string) => {
|
||||
switch (category) {
|
||||
case 'protein':
|
||||
return 'border-red-200 bg-red-50';
|
||||
case 'vegetable':
|
||||
return 'border-green-200 bg-green-50';
|
||||
case 'grain':
|
||||
return 'border-yellow-200 bg-yellow-50';
|
||||
case 'fruit':
|
||||
return 'border-orange-200 bg-orange-50';
|
||||
case 'dairy':
|
||||
return 'border-blue-200 bg-blue-50';
|
||||
case 'fat':
|
||||
return 'border-purple-200 bg-purple-50';
|
||||
case 'processed':
|
||||
return 'border-gray-200 bg-gray-50';
|
||||
case 'beverage':
|
||||
return 'border-cyan-200 bg-cyan-50';
|
||||
default:
|
||||
return 'border-gray-200 bg-gray-50';
|
||||
}
|
||||
};
|
||||
const getCategoryColor = (category: string) => {
|
||||
switch (category) {
|
||||
case 'protein':
|
||||
return 'border-red-200 bg-red-50';
|
||||
case 'vegetable':
|
||||
return 'border-green-200 bg-green-50';
|
||||
case 'grain':
|
||||
return 'border-yellow-200 bg-yellow-50';
|
||||
case 'fruit':
|
||||
return 'border-orange-200 bg-orange-50';
|
||||
case 'dairy':
|
||||
return 'border-blue-200 bg-blue-50';
|
||||
case 'fat':
|
||||
return 'border-purple-200 bg-purple-50';
|
||||
case 'processed':
|
||||
return 'border-gray-200 bg-gray-50';
|
||||
case 'beverage':
|
||||
return 'border-cyan-200 bg-cyan-50';
|
||||
default:
|
||||
return 'border-gray-200 bg-gray-50';
|
||||
}
|
||||
};
|
||||
|
||||
// Group food items by category
|
||||
const groupedByCategory = foodItems.reduce(
|
||||
(acc, item) => {
|
||||
const category = item.category || 'other';
|
||||
if (!acc[category]) {
|
||||
acc[category] = [];
|
||||
}
|
||||
acc[category].push(item);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, FoodItem[]>
|
||||
);
|
||||
// Group food items by category
|
||||
const groupedByCategory = foodItems.reduce(
|
||||
(acc, item) => {
|
||||
const category = item.category || 'other';
|
||||
if (!acc[category]) {
|
||||
acc[category] = [];
|
||||
}
|
||||
acc[category].push(item);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, FoodItem[]>
|
||||
);
|
||||
|
||||
const categoryOrder = [
|
||||
'protein',
|
||||
'vegetable',
|
||||
'grain',
|
||||
'fruit',
|
||||
'dairy',
|
||||
'fat',
|
||||
'beverage',
|
||||
'processed',
|
||||
'other',
|
||||
];
|
||||
const sortedCategories = categoryOrder.filter((category) => groupedByCategory[category]);
|
||||
const otherCategories = Object.keys(groupedByCategory).filter(
|
||||
(category) => !categoryOrder.includes(category)
|
||||
);
|
||||
const allCategories = [...sortedCategories, ...otherCategories];
|
||||
const categoryOrder = [
|
||||
'protein',
|
||||
'vegetable',
|
||||
'grain',
|
||||
'fruit',
|
||||
'dairy',
|
||||
'fat',
|
||||
'beverage',
|
||||
'processed',
|
||||
'other',
|
||||
];
|
||||
const sortedCategories = categoryOrder.filter((category) => groupedByCategory[category]);
|
||||
const otherCategories = Object.keys(groupedByCategory).filter(
|
||||
(category) => !categoryOrder.includes(category)
|
||||
);
|
||||
const allCategories = [...sortedCategories, ...otherCategories];
|
||||
|
||||
const getCategoryName = (category: string) => {
|
||||
switch (category) {
|
||||
case 'protein':
|
||||
return 'Proteine';
|
||||
case 'vegetable':
|
||||
return 'Gemüse';
|
||||
case 'grain':
|
||||
return 'Getreide';
|
||||
case 'fruit':
|
||||
return 'Obst';
|
||||
case 'dairy':
|
||||
return 'Milchprodukte';
|
||||
case 'fat':
|
||||
return 'Fette';
|
||||
case 'processed':
|
||||
return 'Verarbeitete Lebensmittel';
|
||||
case 'beverage':
|
||||
return 'Getränke';
|
||||
default:
|
||||
return 'Sonstige';
|
||||
}
|
||||
};
|
||||
const getCategoryName = (category: string) => {
|
||||
switch (category) {
|
||||
case 'protein':
|
||||
return 'Proteine';
|
||||
case 'vegetable':
|
||||
return 'Gemüse';
|
||||
case 'grain':
|
||||
return 'Getreide';
|
||||
case 'fruit':
|
||||
return 'Obst';
|
||||
case 'dairy':
|
||||
return 'Milchprodukte';
|
||||
case 'fat':
|
||||
return 'Fette';
|
||||
case 'processed':
|
||||
return 'Verarbeitete Lebensmittel';
|
||||
case 'beverage':
|
||||
return 'Getränke';
|
||||
default:
|
||||
return 'Sonstige';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View className="py-4">
|
||||
{showTitle && (
|
||||
<Text className="mb-4 text-lg font-semibold text-gray-900">
|
||||
{title} ({foodItems.length})
|
||||
</Text>
|
||||
)}
|
||||
return (
|
||||
<View className="py-4">
|
||||
{showTitle && (
|
||||
<Text className="mb-4 text-lg font-semibold text-gray-900">
|
||||
{title} ({foodItems.length})
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<ScrollView showsVerticalScrollIndicator={false}>
|
||||
{allCategories.map((category) => (
|
||||
<View key={category} className="mb-6">
|
||||
{/* Category Header */}
|
||||
<View className="mb-3 flex-row items-center">
|
||||
<Text className="mr-2 text-2xl">{getCategoryIcon(category)}</Text>
|
||||
<Text className="text-base font-medium text-gray-800">
|
||||
{getCategoryName(category)} ({groupedByCategory[category].length})
|
||||
</Text>
|
||||
</View>
|
||||
<ScrollView showsVerticalScrollIndicator={false}>
|
||||
{allCategories.map((category) => (
|
||||
<View key={category} className="mb-6">
|
||||
{/* Category Header */}
|
||||
<View className="mb-3 flex-row items-center">
|
||||
<Text className="mr-2 text-2xl">{getCategoryIcon(category)}</Text>
|
||||
<Text className="text-base font-medium text-gray-800">
|
||||
{getCategoryName(category)} ({groupedByCategory[category].length})
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Category Items */}
|
||||
<View className="space-y-2">
|
||||
{groupedByCategory[category].map((item, index) => (
|
||||
<FoodItemCard
|
||||
key={item.id || `${category}-${index}`}
|
||||
foodItem={item}
|
||||
categoryColor={getCategoryColor(category)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
{/* Category Items */}
|
||||
<View className="space-y-2">
|
||||
{groupedByCategory[category].map((item, index) => (
|
||||
<FoodItemCard
|
||||
key={item.id || `${category}-${index}`}
|
||||
foodItem={item}
|
||||
categoryColor={getCategoryColor(category)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,186 +5,186 @@ import { MealWithItems } from '../../types/Database';
|
|||
import { AnalysisStatusIndicator } from './AnalysisStatusIndicator';
|
||||
|
||||
interface MealCardProps {
|
||||
meal: MealWithItems;
|
||||
onPress: () => void;
|
||||
meal: MealWithItems;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
export const MealCard: React.FC<MealCardProps> = ({ meal, onPress }) => {
|
||||
const [imageError, setImageError] = useState(false);
|
||||
const generateMealTitle = (meal: MealWithItems): string => {
|
||||
if (meal.food_items && meal.food_items.length > 0) {
|
||||
const foodNames = meal.food_items.map((item) => item.name);
|
||||
const [imageError, setImageError] = useState(false);
|
||||
const generateMealTitle = (meal: MealWithItems): string => {
|
||||
if (meal.food_items && meal.food_items.length > 0) {
|
||||
const foodNames = meal.food_items.map((item) => item.name);
|
||||
|
||||
if (foodNames.length === 1) {
|
||||
return foodNames[0];
|
||||
} else if (foodNames.length === 2) {
|
||||
return `${foodNames[0]} & ${foodNames[1]}`;
|
||||
} else if (foodNames.length > 2) {
|
||||
return `${foodNames[0]} & ${foodNames.length - 1} more`;
|
||||
}
|
||||
}
|
||||
if (foodNames.length === 1) {
|
||||
return foodNames[0];
|
||||
} else if (foodNames.length === 2) {
|
||||
return `${foodNames[0]} & ${foodNames[1]}`;
|
||||
} else if (foodNames.length > 2) {
|
||||
return `${foodNames[0]} & ${foodNames.length - 1} more`;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to meal type if no food items
|
||||
const mealTypeLabels = {
|
||||
breakfast: 'Breakfast',
|
||||
lunch: 'Lunch',
|
||||
dinner: 'Dinner',
|
||||
snack: 'Snack',
|
||||
};
|
||||
// Fallback to meal type if no food items
|
||||
const mealTypeLabels = {
|
||||
breakfast: 'Breakfast',
|
||||
lunch: 'Lunch',
|
||||
dinner: 'Dinner',
|
||||
snack: 'Snack',
|
||||
};
|
||||
|
||||
return mealTypeLabels[meal.meal_type || 'snack'] || 'Meal';
|
||||
};
|
||||
return mealTypeLabels[meal.meal_type || 'snack'] || 'Meal';
|
||||
};
|
||||
|
||||
const getMealTypeLabel = (mealType?: string): string => {
|
||||
const labels = {
|
||||
breakfast: 'Breakfast',
|
||||
lunch: 'Lunch',
|
||||
dinner: 'Dinner',
|
||||
snack: 'Snack',
|
||||
};
|
||||
return labels[mealType as keyof typeof labels] || 'Meal';
|
||||
};
|
||||
const getMealTypeLabel = (mealType?: string): string => {
|
||||
const labels = {
|
||||
breakfast: 'Breakfast',
|
||||
lunch: 'Lunch',
|
||||
dinner: 'Dinner',
|
||||
snack: 'Snack',
|
||||
};
|
||||
return labels[mealType as keyof typeof labels] || 'Meal';
|
||||
};
|
||||
|
||||
const formatTime = (timestamp: string) => {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
const formatTime = (timestamp: string) => {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
|
||||
if (diffDays > 0) {
|
||||
return `${diffDays}d ago`;
|
||||
} else if (diffHours > 0) {
|
||||
return `${diffHours}h ago`;
|
||||
} else {
|
||||
return 'Now';
|
||||
}
|
||||
};
|
||||
if (diffDays > 0) {
|
||||
return `${diffDays}d ago`;
|
||||
} else if (diffHours > 0) {
|
||||
return `${diffHours}h ago`;
|
||||
} else {
|
||||
return 'Now';
|
||||
}
|
||||
};
|
||||
|
||||
const getMealTypeIcon = (mealType?: string) => {
|
||||
switch (mealType) {
|
||||
case 'breakfast':
|
||||
return '🥐';
|
||||
case 'lunch':
|
||||
return '🥗';
|
||||
case 'dinner':
|
||||
return '🍽️';
|
||||
case 'snack':
|
||||
return '🍎';
|
||||
default:
|
||||
return '🍽️';
|
||||
}
|
||||
};
|
||||
const getMealTypeIcon = (mealType?: string) => {
|
||||
switch (mealType) {
|
||||
case 'breakfast':
|
||||
return '🥐';
|
||||
case 'lunch':
|
||||
return '🥗';
|
||||
case 'dinner':
|
||||
return '🍽️';
|
||||
case 'snack':
|
||||
return '🍎';
|
||||
default:
|
||||
return '🍽️';
|
||||
}
|
||||
};
|
||||
|
||||
const getHealthScoreColor = (score?: number) => {
|
||||
if (!score) return 'text-gray-400';
|
||||
if (score >= 80) return 'text-green-400';
|
||||
if (score >= 60) return 'text-yellow-400';
|
||||
return 'text-red-400';
|
||||
};
|
||||
const getHealthScoreColor = (score?: number) => {
|
||||
if (!score) return 'text-gray-400';
|
||||
if (score >= 80) return 'text-green-400';
|
||||
if (score >= 60) return 'text-yellow-400';
|
||||
return 'text-red-400';
|
||||
};
|
||||
|
||||
return (
|
||||
<TouchableOpacity onPress={onPress} activeOpacity={0.8}>
|
||||
<View className="aspect-square overflow-hidden rounded-2xl bg-gray-200 shadow-lg">
|
||||
{/* Background Image */}
|
||||
{meal.photo_path && !imageError ? (
|
||||
<Image
|
||||
source={{ uri: meal.photo_path }}
|
||||
className="h-full w-full"
|
||||
resizeMode="cover"
|
||||
onError={(error) => {
|
||||
console.error('MealCard image loading error:', error);
|
||||
console.log('MealCard photo_path:', meal.photo_path);
|
||||
setImageError(true);
|
||||
}}
|
||||
onLoad={() => {
|
||||
console.log('MealCard image loaded successfully:', meal.photo_path);
|
||||
setImageError(false);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<View className="h-full w-full items-center justify-center bg-gray-300">
|
||||
<Text className="text-6xl">{getMealTypeIcon(meal.meal_type)}</Text>
|
||||
</View>
|
||||
)}
|
||||
return (
|
||||
<TouchableOpacity onPress={onPress} activeOpacity={0.8}>
|
||||
<View className="aspect-square overflow-hidden rounded-2xl bg-gray-200 shadow-lg">
|
||||
{/* Background Image */}
|
||||
{meal.photo_path && !imageError ? (
|
||||
<Image
|
||||
source={{ uri: meal.photo_path }}
|
||||
className="h-full w-full"
|
||||
resizeMode="cover"
|
||||
onError={(error) => {
|
||||
console.error('MealCard image loading error:', error);
|
||||
console.log('MealCard photo_path:', meal.photo_path);
|
||||
setImageError(true);
|
||||
}}
|
||||
onLoad={() => {
|
||||
console.log('MealCard image loaded successfully:', meal.photo_path);
|
||||
setImageError(false);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<View className="h-full w-full items-center justify-center bg-gray-300">
|
||||
<Text className="text-6xl">{getMealTypeIcon(meal.meal_type)}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Blurry Stats Overlay */}
|
||||
<View className="absolute bottom-0 left-0 right-0">
|
||||
<View className="bg-black/70 px-4 py-3 backdrop-blur-sm">
|
||||
<View className="flex-row items-start justify-between">
|
||||
{/* Left side - Meal info */}
|
||||
<View className="flex-1">
|
||||
<Text className="text-lg font-bold text-white" numberOfLines={1}>
|
||||
{generateMealTitle(meal)}
|
||||
</Text>
|
||||
<View className="flex-row items-center space-x-2">
|
||||
<Text className="text-sm text-gray-300">{getMealTypeLabel(meal.meal_type)}</Text>
|
||||
<Text className="text-sm text-gray-400">•</Text>
|
||||
<Text className="text-sm text-gray-300">{formatTime(meal.timestamp)}</Text>
|
||||
</View>
|
||||
{/* Location if available */}
|
||||
{meal.location && (
|
||||
<View className="mt-1 flex-row items-center">
|
||||
<Ionicons name="location-outline" size={12} color="#d1d5db" />
|
||||
<Text className="ml-1 text-xs text-gray-300" numberOfLines={1}>
|
||||
{meal.location}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
{/* Blurry Stats Overlay */}
|
||||
<View className="absolute bottom-0 left-0 right-0">
|
||||
<View className="bg-black/70 px-4 py-3 backdrop-blur-sm">
|
||||
<View className="flex-row items-start justify-between">
|
||||
{/* Left side - Meal info */}
|
||||
<View className="flex-1">
|
||||
<Text className="text-lg font-bold text-white" numberOfLines={1}>
|
||||
{generateMealTitle(meal)}
|
||||
</Text>
|
||||
<View className="flex-row items-center space-x-2">
|
||||
<Text className="text-sm text-gray-300">{getMealTypeLabel(meal.meal_type)}</Text>
|
||||
<Text className="text-sm text-gray-400">•</Text>
|
||||
<Text className="text-sm text-gray-300">{formatTime(meal.timestamp)}</Text>
|
||||
</View>
|
||||
{/* Location if available */}
|
||||
{meal.location && (
|
||||
<View className="mt-1 flex-row items-center">
|
||||
<Ionicons name="location-outline" size={12} color="#d1d5db" />
|
||||
<Text className="ml-1 text-xs text-gray-300" numberOfLines={1}>
|
||||
{meal.location}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Right side - Stats */}
|
||||
<View className="items-end">
|
||||
{meal.analysis_status === 'completed' && (
|
||||
<View className="flex-row items-center space-x-3">
|
||||
{/* Calories */}
|
||||
{meal.total_calories && (
|
||||
<View className="items-center">
|
||||
<Text className="text-xs text-gray-300">cal</Text>
|
||||
<Text className="font-bold text-white">
|
||||
{Math.round(meal.total_calories)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{/* Right side - Stats */}
|
||||
<View className="items-end">
|
||||
{meal.analysis_status === 'completed' && (
|
||||
<View className="flex-row items-center space-x-3">
|
||||
{/* Calories */}
|
||||
{meal.total_calories && (
|
||||
<View className="items-center">
|
||||
<Text className="text-xs text-gray-300">cal</Text>
|
||||
<Text className="font-bold text-white">
|
||||
{Math.round(meal.total_calories)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Health Score */}
|
||||
{meal.health_score && (
|
||||
<View className="items-center">
|
||||
<Text className="text-xs text-gray-300">health</Text>
|
||||
<Text className={`font-bold ${getHealthScoreColor(meal.health_score)}`}>
|
||||
{Math.round(meal.health_score)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{/* Health Score */}
|
||||
{meal.health_score && (
|
||||
<View className="items-center">
|
||||
<Text className="text-xs text-gray-300">health</Text>
|
||||
<Text className={`font-bold ${getHealthScoreColor(meal.health_score)}`}>
|
||||
{Math.round(meal.health_score)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Rating */}
|
||||
{meal.user_rating && (
|
||||
<View className="items-center">
|
||||
<Text className="text-xs text-gray-300">rating</Text>
|
||||
<Text className="font-bold text-yellow-400">{meal.user_rating}/5</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
{/* Rating */}
|
||||
{meal.user_rating && (
|
||||
<View className="items-center">
|
||||
<Text className="text-xs text-gray-300">rating</Text>
|
||||
<Text className="font-bold text-yellow-400">{meal.user_rating}/5</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Analysis Status for non-completed */}
|
||||
{meal.analysis_status !== 'completed' && (
|
||||
<View className="rounded-full bg-black/30 p-1">
|
||||
<AnalysisStatusIndicator status={meal.analysis_status} mini={true} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
{/* Analysis Status for non-completed */}
|
||||
{meal.analysis_status !== 'completed' && (
|
||||
<View className="rounded-full bg-black/30 p-1">
|
||||
<AnalysisStatusIndicator status={meal.analysis_status} mini={true} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* User Notes */}
|
||||
{meal.user_notes && (
|
||||
<Text className="mt-2 text-sm italic text-gray-200" numberOfLines={1}>
|
||||
“{meal.user_notes}”
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
{/* User Notes */}
|
||||
{meal.user_notes && (
|
||||
<Text className="mt-2 text-sm italic text-gray-200" numberOfLines={1}>
|
||||
“{meal.user_notes}”
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -8,39 +8,39 @@ import { EditMealModal } from './EditMealModal';
|
|||
import { useMealStore } from '../../store/MealStore';
|
||||
|
||||
interface MealCardContextMenuProps {
|
||||
meal: MealWithItems;
|
||||
onPress: () => void;
|
||||
meal: MealWithItems;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
export const MealCardContextMenu: React.FC<MealCardContextMenuProps> = ({ meal, onPress }) => {
|
||||
const { deleteMeal, updateMeal } = useMealStore();
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const { deleteMeal, updateMeal } = useMealStore();
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
|
||||
const handleDelete = () => {
|
||||
Alert.alert(
|
||||
'Mahlzeit löschen',
|
||||
'Möchtest du diese Mahlzeit wirklich löschen?',
|
||||
[
|
||||
{ text: 'Abbrechen', style: 'cancel' },
|
||||
{
|
||||
text: 'Löschen',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
try {
|
||||
await deleteMeal(meal.id);
|
||||
} catch {
|
||||
Alert.alert('Fehler', 'Die Mahlzeit konnte nicht gelöscht werden.');
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
{ cancelable: true }
|
||||
);
|
||||
};
|
||||
const handleDelete = () => {
|
||||
Alert.alert(
|
||||
'Mahlzeit löschen',
|
||||
'Möchtest du diese Mahlzeit wirklich löschen?',
|
||||
[
|
||||
{ text: 'Abbrechen', style: 'cancel' },
|
||||
{
|
||||
text: 'Löschen',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
try {
|
||||
await deleteMeal(meal.id);
|
||||
} catch {
|
||||
Alert.alert('Fehler', 'Die Mahlzeit konnte nicht gelöscht werden.');
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
{ cancelable: true }
|
||||
);
|
||||
};
|
||||
|
||||
const handleShare = async () => {
|
||||
try {
|
||||
const nutritionInfo = `🍽️ ${meal.food_items?.map((item) => item.name).join(', ') || 'Mahlzeit'}
|
||||
const handleShare = async () => {
|
||||
try {
|
||||
const nutritionInfo = `🍽️ ${meal.food_items?.map((item) => item.name).join(', ') || 'Mahlzeit'}
|
||||
|
||||
📊 Nährwerte:
|
||||
• Kalorien: ${meal.total_calories || '--'} kcal
|
||||
|
|
@ -52,33 +52,33 @@ export const MealCardContextMenu: React.FC<MealCardContextMenuProps> = ({ meal,
|
|||
|
||||
Getrackt mit Nutriphi 🤖`;
|
||||
|
||||
await Share.share({
|
||||
message: nutritionInfo,
|
||||
title: 'Meine Mahlzeit',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Share failed:', error);
|
||||
}
|
||||
};
|
||||
await Share.share({
|
||||
message: nutritionInfo,
|
||||
title: 'Meine Mahlzeit',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Share failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRating = (rating: number) => {
|
||||
updateMeal(meal.id, { user_rating: rating });
|
||||
};
|
||||
const handleRating = (rating: number) => {
|
||||
updateMeal(meal.id, { user_rating: rating });
|
||||
};
|
||||
|
||||
const handleReanalyze = () => {
|
||||
Alert.alert(
|
||||
'Erneut analysieren',
|
||||
'Die Funktion zur erneuten Analyse wird in einer zukünftigen Version verfügbar sein.',
|
||||
[{ text: 'OK' }]
|
||||
);
|
||||
};
|
||||
const handleReanalyze = () => {
|
||||
Alert.alert(
|
||||
'Erneut analysieren',
|
||||
'Die Funktion zur erneuten Analyse wird in einer zukünftigen Version verfügbar sein.',
|
||||
[{ text: 'OK' }]
|
||||
);
|
||||
};
|
||||
|
||||
const handleEdit = () => {
|
||||
setShowEditModal(true);
|
||||
};
|
||||
const handleEdit = () => {
|
||||
setShowEditModal(true);
|
||||
};
|
||||
|
||||
const handleCopyNutrition = () => {
|
||||
const nutritionText = `${meal.food_items?.map((item) => item.name).join(', ') || 'Mahlzeit'}
|
||||
const handleCopyNutrition = () => {
|
||||
const nutritionText = `${meal.food_items?.map((item) => item.name).join(', ') || 'Mahlzeit'}
|
||||
Kalorien: ${meal.total_calories || '--'} kcal
|
||||
Protein: ${meal.total_protein || '--'}g
|
||||
Kohlenhydrate: ${meal.total_carbs || '--'}g
|
||||
|
|
@ -87,100 +87,100 @@ Ballaststoffe: ${meal.total_fiber || '--'}g
|
|||
Zucker: ${meal.total_sugar || '--'}g
|
||||
Gesundheitsscore: ${meal.health_score ? Math.round(meal.health_score) : '--'}/100`;
|
||||
|
||||
Clipboard.setString(nutritionText);
|
||||
Alert.alert('Kopiert', 'Nährwerte wurden in die Zwischenablage kopiert.');
|
||||
};
|
||||
Clipboard.setString(nutritionText);
|
||||
Alert.alert('Kopiert', 'Nährwerte wurden in die Zwischenablage kopiert.');
|
||||
};
|
||||
|
||||
// Build context menu actions
|
||||
const actions = [
|
||||
{
|
||||
title: 'Bearbeiten',
|
||||
systemIcon: 'pencil',
|
||||
},
|
||||
{
|
||||
title: 'Bewerten',
|
||||
systemIcon: 'star',
|
||||
inlineChildren: true,
|
||||
actions: [
|
||||
{ title: '⭐', systemIcon: 'star.fill' },
|
||||
{ title: '⭐⭐', systemIcon: 'star.fill' },
|
||||
{ title: '⭐⭐⭐', systemIcon: 'star.fill' },
|
||||
{ title: '⭐⭐⭐⭐', systemIcon: 'star.fill' },
|
||||
{ title: '⭐⭐⭐⭐⭐', systemIcon: 'star.fill' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Teilen',
|
||||
systemIcon: 'square.and.arrow.up',
|
||||
},
|
||||
{
|
||||
title: 'Nährwerte kopieren',
|
||||
systemIcon: 'doc.on.doc',
|
||||
},
|
||||
];
|
||||
// Build context menu actions
|
||||
const actions = [
|
||||
{
|
||||
title: 'Bearbeiten',
|
||||
systemIcon: 'pencil',
|
||||
},
|
||||
{
|
||||
title: 'Bewerten',
|
||||
systemIcon: 'star',
|
||||
inlineChildren: true,
|
||||
actions: [
|
||||
{ title: '⭐', systemIcon: 'star.fill' },
|
||||
{ title: '⭐⭐', systemIcon: 'star.fill' },
|
||||
{ title: '⭐⭐⭐', systemIcon: 'star.fill' },
|
||||
{ title: '⭐⭐⭐⭐', systemIcon: 'star.fill' },
|
||||
{ title: '⭐⭐⭐⭐⭐', systemIcon: 'star.fill' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Teilen',
|
||||
systemIcon: 'square.and.arrow.up',
|
||||
},
|
||||
{
|
||||
title: 'Nährwerte kopieren',
|
||||
systemIcon: 'doc.on.doc',
|
||||
},
|
||||
];
|
||||
|
||||
// Add conditional actions
|
||||
if (meal.analysis_status === 'failed') {
|
||||
actions.push({
|
||||
title: 'Erneut analysieren',
|
||||
systemIcon: 'arrow.clockwise',
|
||||
});
|
||||
}
|
||||
// Add conditional actions
|
||||
if (meal.analysis_status === 'failed') {
|
||||
actions.push({
|
||||
title: 'Erneut analysieren',
|
||||
systemIcon: 'arrow.clockwise',
|
||||
});
|
||||
}
|
||||
|
||||
// Add destructive action at the end
|
||||
actions.push({
|
||||
title: 'Löschen',
|
||||
systemIcon: 'trash',
|
||||
destructive: true,
|
||||
});
|
||||
// Add destructive action at the end
|
||||
actions.push({
|
||||
title: 'Löschen',
|
||||
systemIcon: 'trash',
|
||||
destructive: true,
|
||||
});
|
||||
|
||||
const handlePress = (event: any) => {
|
||||
const { index, name } = event.nativeEvent;
|
||||
const handlePress = (event: any) => {
|
||||
const { index, name } = event.nativeEvent;
|
||||
|
||||
// Haptic feedback
|
||||
Vibration.vibrate(10);
|
||||
// Haptic feedback
|
||||
Vibration.vibrate(10);
|
||||
|
||||
switch (name || actions[index]?.title) {
|
||||
case 'Bearbeiten':
|
||||
handleEdit();
|
||||
break;
|
||||
case 'Löschen':
|
||||
handleDelete();
|
||||
break;
|
||||
case 'Teilen':
|
||||
handleShare();
|
||||
break;
|
||||
case 'Nährwerte kopieren':
|
||||
handleCopyNutrition();
|
||||
break;
|
||||
case 'Erneut analysieren':
|
||||
handleReanalyze();
|
||||
break;
|
||||
case '⭐':
|
||||
handleRating(1);
|
||||
break;
|
||||
case '⭐⭐':
|
||||
handleRating(2);
|
||||
break;
|
||||
case '⭐⭐⭐':
|
||||
handleRating(3);
|
||||
break;
|
||||
case '⭐⭐⭐⭐':
|
||||
handleRating(4);
|
||||
break;
|
||||
case '⭐⭐⭐⭐⭐':
|
||||
handleRating(5);
|
||||
break;
|
||||
}
|
||||
};
|
||||
switch (name || actions[index]?.title) {
|
||||
case 'Bearbeiten':
|
||||
handleEdit();
|
||||
break;
|
||||
case 'Löschen':
|
||||
handleDelete();
|
||||
break;
|
||||
case 'Teilen':
|
||||
handleShare();
|
||||
break;
|
||||
case 'Nährwerte kopieren':
|
||||
handleCopyNutrition();
|
||||
break;
|
||||
case 'Erneut analysieren':
|
||||
handleReanalyze();
|
||||
break;
|
||||
case '⭐':
|
||||
handleRating(1);
|
||||
break;
|
||||
case '⭐⭐':
|
||||
handleRating(2);
|
||||
break;
|
||||
case '⭐⭐⭐':
|
||||
handleRating(3);
|
||||
break;
|
||||
case '⭐⭐⭐⭐':
|
||||
handleRating(4);
|
||||
break;
|
||||
case '⭐⭐⭐⭐⭐':
|
||||
handleRating(5);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContextMenu actions={actions} onPress={handlePress} previewBackgroundColor="transparent">
|
||||
<MealCard meal={meal} onPress={onPress} />
|
||||
</ContextMenu>
|
||||
return (
|
||||
<>
|
||||
<ContextMenu actions={actions} onPress={handlePress} previewBackgroundColor="transparent">
|
||||
<MealCard meal={meal} onPress={onPress} />
|
||||
</ContextMenu>
|
||||
|
||||
<EditMealModal meal={meal} visible={showEditModal} onClose={() => setShowEditModal(false)} />
|
||||
</>
|
||||
);
|
||||
<EditMealModal meal={meal} visible={showEditModal} onClose={() => setShowEditModal(false)} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,145 +5,146 @@ import { Card } from '../ui/Card';
|
|||
import { NutritionBar } from './NutritionBar';
|
||||
|
||||
interface MealItemProps {
|
||||
meal: Meal;
|
||||
onPress: () => void;
|
||||
meal: Meal;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
export const MealItem: React.FC<MealItemProps> = ({ meal, onPress }) => {
|
||||
const formatTime = (timestamp: string) => {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
const formatTime = (timestamp: string) => {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
|
||||
if (diffDays > 0) {
|
||||
return `${diffDays}d ago`;
|
||||
} else if (diffHours > 0) {
|
||||
return `${diffHours}h ago`;
|
||||
} else {
|
||||
return 'Now';
|
||||
}
|
||||
};
|
||||
if (diffDays > 0) {
|
||||
return `${diffDays}d ago`;
|
||||
} else if (diffHours > 0) {
|
||||
return `${diffHours}h ago`;
|
||||
} else {
|
||||
return 'Now';
|
||||
}
|
||||
};
|
||||
|
||||
const getMealTypeIcon = (mealType?: string) => {
|
||||
switch (mealType) {
|
||||
case 'breakfast':
|
||||
return '🥐';
|
||||
case 'lunch':
|
||||
return '🥗';
|
||||
case 'dinner':
|
||||
return '🍽️';
|
||||
case 'snack':
|
||||
return '🍎';
|
||||
default:
|
||||
return '🍽️';
|
||||
}
|
||||
};
|
||||
const getMealTypeIcon = (mealType?: string) => {
|
||||
switch (mealType) {
|
||||
case 'breakfast':
|
||||
return '🥐';
|
||||
case 'lunch':
|
||||
return '🥗';
|
||||
case 'dinner':
|
||||
return '🍽️';
|
||||
case 'snack':
|
||||
return '🍎';
|
||||
default:
|
||||
return '🍽️';
|
||||
}
|
||||
};
|
||||
|
||||
const getAnalysisStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return 'bg-green-100 text-green-800';
|
||||
case 'pending':
|
||||
return 'bg-yellow-100 text-yellow-800';
|
||||
case 'failed':
|
||||
return 'bg-red-100 text-red-800';
|
||||
case 'manual':
|
||||
return 'bg-blue-100 text-blue-800';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
const getAnalysisStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return 'bg-green-100 text-green-800';
|
||||
case 'pending':
|
||||
return 'bg-yellow-100 text-yellow-800';
|
||||
case 'failed':
|
||||
return 'bg-red-100 text-red-800';
|
||||
case 'manual':
|
||||
return 'bg-blue-100 text-blue-800';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
const getAnalysisStatusText = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return 'Analyzed';
|
||||
case 'pending':
|
||||
return 'Processing...';
|
||||
case 'failed':
|
||||
return 'Failed';
|
||||
case 'manual':
|
||||
return 'Manual';
|
||||
default:
|
||||
return 'Unknown';
|
||||
}
|
||||
};
|
||||
const getAnalysisStatusText = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return 'Analyzed';
|
||||
case 'pending':
|
||||
return 'Processing...';
|
||||
case 'failed':
|
||||
return 'Failed';
|
||||
case 'manual':
|
||||
return 'Manual';
|
||||
default:
|
||||
return 'Unknown';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TouchableOpacity onPress={onPress} activeOpacity={0.7}>
|
||||
<Card variant="elevated" className="mb-4">
|
||||
<View className="flex-row space-x-4">
|
||||
{/* Photo */}
|
||||
<View className="h-20 w-20 overflow-hidden rounded-lg bg-gray-200">
|
||||
{meal.photo_path ? (
|
||||
<Image
|
||||
source={{ uri: `file://${meal.photo_path}` }}
|
||||
className="h-full w-full"
|
||||
resizeMode="cover"
|
||||
/>
|
||||
) : (
|
||||
<View className="h-full w-full items-center justify-center">
|
||||
<Text className="text-2xl">{getMealTypeIcon(meal.meal_type)}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
return (
|
||||
<TouchableOpacity onPress={onPress} activeOpacity={0.7}>
|
||||
<Card variant="elevated" className="mb-4">
|
||||
<View className="flex-row space-x-4">
|
||||
{/* Photo */}
|
||||
<View className="h-20 w-20 overflow-hidden rounded-lg bg-gray-200">
|
||||
{meal.photo_path ? (
|
||||
<Image
|
||||
source={{ uri: `file://${meal.photo_path}` }}
|
||||
className="h-full w-full"
|
||||
resizeMode="cover"
|
||||
/>
|
||||
) : (
|
||||
<View className="h-full w-full items-center justify-center">
|
||||
<Text className="text-2xl">{getMealTypeIcon(meal.meal_type)}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Content */}
|
||||
<View className="flex-1 space-y-2">
|
||||
{/* Header */}
|
||||
<View className="flex-row items-start justify-between">
|
||||
<View>
|
||||
<Text className="text-lg font-semibold capitalize text-gray-900">
|
||||
{meal.meal_type || 'Meal'}
|
||||
</Text>
|
||||
<Text className="text-sm text-gray-500">{formatTime(meal.timestamp)}</Text>
|
||||
</View>
|
||||
{/* Content */}
|
||||
<View className="flex-1 space-y-2">
|
||||
{/* Header */}
|
||||
<View className="flex-row items-start justify-between">
|
||||
<View>
|
||||
<Text className="text-lg font-semibold capitalize text-gray-900">
|
||||
{meal.meal_type || 'Meal'}
|
||||
</Text>
|
||||
<Text className="text-sm text-gray-500">{formatTime(meal.timestamp)}</Text>
|
||||
</View>
|
||||
|
||||
<View
|
||||
className={`rounded-full px-2 py-1 ${getAnalysisStatusColor(meal.analysis_status)}`}>
|
||||
<Text className="text-xs font-medium">
|
||||
{getAnalysisStatusText(meal.analysis_status)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View
|
||||
className={`rounded-full px-2 py-1 ${getAnalysisStatusColor(meal.analysis_status)}`}
|
||||
>
|
||||
<Text className="text-xs font-medium">
|
||||
{getAnalysisStatusText(meal.analysis_status)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Nutrition Summary */}
|
||||
{meal.analysis_status === 'completed' && (
|
||||
<NutritionBar
|
||||
calories={meal.total_calories}
|
||||
healthScore={meal.health_score}
|
||||
compact={true}
|
||||
/>
|
||||
)}
|
||||
{/* Nutrition Summary */}
|
||||
{meal.analysis_status === 'completed' && (
|
||||
<NutritionBar
|
||||
calories={meal.total_calories}
|
||||
healthScore={meal.health_score}
|
||||
compact={true}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Notes */}
|
||||
{meal.user_notes && (
|
||||
<Text className="text-sm italic text-gray-600" numberOfLines={2}>
|
||||
“{meal.user_notes}”
|
||||
</Text>
|
||||
)}
|
||||
{/* Notes */}
|
||||
{meal.user_notes && (
|
||||
<Text className="text-sm italic text-gray-600" numberOfLines={2}>
|
||||
“{meal.user_notes}”
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Bottom Info */}
|
||||
<View className="flex-row items-center justify-between">
|
||||
<View className="flex-row items-center space-x-2">
|
||||
{meal.location && <Text className="text-xs text-gray-500">📍 {meal.location}</Text>}
|
||||
</View>
|
||||
{/* Bottom Info */}
|
||||
<View className="flex-row items-center justify-between">
|
||||
<View className="flex-row items-center space-x-2">
|
||||
{meal.location && <Text className="text-xs text-gray-500">📍 {meal.location}</Text>}
|
||||
</View>
|
||||
|
||||
{meal.user_rating && (
|
||||
<View className="flex-row">
|
||||
{Array.from({ length: 5 }, (_, i) => (
|
||||
<Text key={i} className="text-xs">
|
||||
{i < meal.user_rating! ? '⭐' : '☆'}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Card>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
{meal.user_rating && (
|
||||
<View className="flex-row">
|
||||
{Array.from({ length: 5 }, (_, i) => (
|
||||
<Text key={i} className="text-xs">
|
||||
{i < meal.user_rating! ? '⭐' : '☆'}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Card>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -8,93 +8,93 @@ import { Button } from '../Button';
|
|||
import { Header } from '../ui/Header';
|
||||
|
||||
interface MealListProps {
|
||||
onMealPress: (meal: MealWithItems) => void;
|
||||
onMealPress: (meal: MealWithItems) => void;
|
||||
}
|
||||
|
||||
export const MealList: React.FC<MealListProps> = ({ onMealPress }) => {
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const { meals, isLoading, error, loadMeals, clearError } = useMealStore();
|
||||
const { meals, isLoading, error, loadMeals, clearError } = useMealStore();
|
||||
|
||||
useEffect(() => {
|
||||
loadMeals();
|
||||
}, [loadMeals]);
|
||||
useEffect(() => {
|
||||
loadMeals();
|
||||
}, [loadMeals]);
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
await loadMeals();
|
||||
setRefreshing(false);
|
||||
};
|
||||
const handleRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
await loadMeals();
|
||||
setRefreshing(false);
|
||||
};
|
||||
|
||||
const renderMealItem = ({ item }: { item: MealWithItems }) => (
|
||||
<MealCardContextMenu meal={item} onPress={() => onMealPress(item)} />
|
||||
);
|
||||
const renderMealItem = ({ item }: { item: MealWithItems }) => (
|
||||
<MealCardContextMenu meal={item} onPress={() => onMealPress(item)} />
|
||||
);
|
||||
|
||||
const renderEmptyState = () => (
|
||||
<View className="flex-1 items-center justify-center py-20">
|
||||
<Text className="mb-4 text-6xl">🍽️</Text>
|
||||
<Text className="mb-2 text-xl font-semibold text-gray-800 dark:text-gray-200">
|
||||
No meals yet
|
||||
</Text>
|
||||
<Text className="px-8 text-center text-gray-600 dark:text-gray-400">
|
||||
Start tracking your nutrition by taking a photo of your first meal using the camera button
|
||||
below!
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
const renderEmptyState = () => (
|
||||
<View className="flex-1 items-center justify-center py-20">
|
||||
<Text className="mb-4 text-6xl">🍽️</Text>
|
||||
<Text className="mb-2 text-xl font-semibold text-gray-800 dark:text-gray-200">
|
||||
No meals yet
|
||||
</Text>
|
||||
<Text className="px-8 text-center text-gray-600 dark:text-gray-400">
|
||||
Start tracking your nutrition by taking a photo of your first meal using the camera button
|
||||
below!
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
const renderError = () => (
|
||||
<View className="flex-1 items-center justify-center py-20">
|
||||
<Text className="mb-4 text-4xl">⚠️</Text>
|
||||
<Text className="mb-2 text-xl font-semibold text-red-600 dark:text-red-400">
|
||||
Oops! Something went wrong
|
||||
</Text>
|
||||
<Text className="mb-6 px-8 text-center text-gray-600 dark:text-gray-400">{error}</Text>
|
||||
<Button
|
||||
title="Try Again"
|
||||
onPress={() => {
|
||||
clearError();
|
||||
loadMeals();
|
||||
}}
|
||||
className="px-8"
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
const renderError = () => (
|
||||
<View className="flex-1 items-center justify-center py-20">
|
||||
<Text className="mb-4 text-4xl">⚠️</Text>
|
||||
<Text className="mb-2 text-xl font-semibold text-red-600 dark:text-red-400">
|
||||
Oops! Something went wrong
|
||||
</Text>
|
||||
<Text className="mb-6 px-8 text-center text-gray-600 dark:text-gray-400">{error}</Text>
|
||||
<Button
|
||||
title="Try Again"
|
||||
onPress={() => {
|
||||
clearError();
|
||||
loadMeals();
|
||||
}}
|
||||
className="px-8"
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
if (error && meals.length === 0) {
|
||||
return renderError();
|
||||
}
|
||||
if (error && meals.length === 0) {
|
||||
return renderError();
|
||||
}
|
||||
|
||||
if (isLoading && meals.length === 0) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<LoadingSpinner text="Loading your meals..." />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (isLoading && meals.length === 0) {
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<LoadingSpinner text="Loading your meals..." />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-gray-50 dark:bg-gray-900">
|
||||
{/* Meal List */}
|
||||
<FlatList
|
||||
data={meals}
|
||||
renderItem={renderMealItem}
|
||||
keyExtractor={(item) => item.id!.toString()}
|
||||
contentContainerStyle={{
|
||||
padding: 16,
|
||||
flexGrow: 1,
|
||||
}}
|
||||
ItemSeparatorComponent={() => <View style={{ height: 16 }} />}
|
||||
ListEmptyComponent={renderEmptyState}
|
||||
ListHeaderComponent={<Header title="NutriPhi" />}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} tintColor="#6366f1" />
|
||||
}
|
||||
showsVerticalScrollIndicator={false}
|
||||
/>
|
||||
return (
|
||||
<View className="flex-1 bg-gray-50 dark:bg-gray-900">
|
||||
{/* Meal List */}
|
||||
<FlatList
|
||||
data={meals}
|
||||
renderItem={renderMealItem}
|
||||
keyExtractor={(item) => item.id!.toString()}
|
||||
contentContainerStyle={{
|
||||
padding: 16,
|
||||
flexGrow: 1,
|
||||
}}
|
||||
ItemSeparatorComponent={() => <View style={{ height: 16 }} />}
|
||||
ListEmptyComponent={renderEmptyState}
|
||||
ListHeaderComponent={<Header title="NutriPhi" />}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} tintColor="#6366f1" />
|
||||
}
|
||||
showsVerticalScrollIndicator={false}
|
||||
/>
|
||||
|
||||
{/* Loading Overlay */}
|
||||
{isLoading && meals.length > 0 && <LoadingSpinner overlay text="Updating..." />}
|
||||
</View>
|
||||
);
|
||||
{/* Loading Overlay */}
|
||||
{isLoading && meals.length > 0 && <LoadingSpinner overlay text="Updating..." />}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,197 +3,197 @@ import { View, Text } from 'react-native';
|
|||
import { Meal } from '@/types/Database';
|
||||
|
||||
interface NutritionBarProps {
|
||||
calories?: number;
|
||||
protein?: number;
|
||||
carbs?: number;
|
||||
fat?: number;
|
||||
healthScore?: number;
|
||||
compact?: boolean;
|
||||
meal?: Meal;
|
||||
showDetailed?: boolean;
|
||||
calories?: number;
|
||||
protein?: number;
|
||||
carbs?: number;
|
||||
fat?: number;
|
||||
healthScore?: number;
|
||||
compact?: boolean;
|
||||
meal?: Meal;
|
||||
showDetailed?: boolean;
|
||||
}
|
||||
|
||||
export const NutritionBar: React.FC<NutritionBarProps> = ({
|
||||
calories,
|
||||
protein,
|
||||
carbs,
|
||||
fat,
|
||||
healthScore,
|
||||
compact = false,
|
||||
meal,
|
||||
showDetailed = false,
|
||||
calories,
|
||||
protein,
|
||||
carbs,
|
||||
fat,
|
||||
healthScore,
|
||||
compact = false,
|
||||
meal,
|
||||
showDetailed = false,
|
||||
}) => {
|
||||
// Use meal data if provided, otherwise use individual props
|
||||
const mealCalories = meal?.total_calories || calories;
|
||||
const mealProtein = meal?.total_protein || protein;
|
||||
const mealCarbs = meal?.total_carbs || carbs;
|
||||
const mealFat = meal?.total_fat || fat;
|
||||
const mealHealthScore = meal?.health_score || healthScore;
|
||||
const mealFiber = meal?.total_fiber;
|
||||
const mealSugar = meal?.total_sugar;
|
||||
const formatValue = (value?: number, unit: string = 'g') => {
|
||||
if (value === undefined || value === null) return '--';
|
||||
return `${Math.round(value)}${unit}`;
|
||||
};
|
||||
// Use meal data if provided, otherwise use individual props
|
||||
const mealCalories = meal?.total_calories || calories;
|
||||
const mealProtein = meal?.total_protein || protein;
|
||||
const mealCarbs = meal?.total_carbs || carbs;
|
||||
const mealFat = meal?.total_fat || fat;
|
||||
const mealHealthScore = meal?.health_score || healthScore;
|
||||
const mealFiber = meal?.total_fiber;
|
||||
const mealSugar = meal?.total_sugar;
|
||||
const formatValue = (value?: number, unit: string = 'g') => {
|
||||
if (value === undefined || value === null) return '--';
|
||||
return `${Math.round(value)}${unit}`;
|
||||
};
|
||||
|
||||
const getHealthScoreColor = (score?: number) => {
|
||||
if (!score) return 'bg-gray-300';
|
||||
if (score >= 8) return 'bg-green-500';
|
||||
if (score >= 6) return 'bg-yellow-500';
|
||||
if (score >= 4) return 'bg-orange-500';
|
||||
return 'bg-red-500';
|
||||
};
|
||||
const getHealthScoreColor = (score?: number) => {
|
||||
if (!score) return 'bg-gray-300';
|
||||
if (score >= 8) return 'bg-green-500';
|
||||
if (score >= 6) return 'bg-yellow-500';
|
||||
if (score >= 4) return 'bg-orange-500';
|
||||
return 'bg-red-500';
|
||||
};
|
||||
|
||||
const getHealthScoreText = (score?: number) => {
|
||||
if (!score) return 'Not analyzed';
|
||||
if (score >= 8) return 'Very Healthy';
|
||||
if (score >= 6) return 'Healthy';
|
||||
if (score >= 4) return 'Moderate';
|
||||
return 'Unhealthy';
|
||||
};
|
||||
const getHealthScoreText = (score?: number) => {
|
||||
if (!score) return 'Not analyzed';
|
||||
if (score >= 8) return 'Very Healthy';
|
||||
if (score >= 6) return 'Healthy';
|
||||
if (score >= 4) return 'Moderate';
|
||||
return 'Unhealthy';
|
||||
};
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<View className="flex-row items-center space-x-4">
|
||||
<View className="flex-row items-center space-x-1">
|
||||
<Text className="text-lg font-bold text-gray-900">
|
||||
{formatValue(mealCalories, ' kcal')}
|
||||
</Text>
|
||||
</View>
|
||||
{mealHealthScore && (
|
||||
<View className="flex-row items-center space-x-2">
|
||||
<View className={`h-3 w-3 rounded-full ${getHealthScoreColor(mealHealthScore)}`} />
|
||||
<Text className="text-sm text-gray-600">{mealHealthScore.toFixed(1)}/10</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (compact) {
|
||||
return (
|
||||
<View className="flex-row items-center space-x-4">
|
||||
<View className="flex-row items-center space-x-1">
|
||||
<Text className="text-lg font-bold text-gray-900">
|
||||
{formatValue(mealCalories, ' kcal')}
|
||||
</Text>
|
||||
</View>
|
||||
{mealHealthScore && (
|
||||
<View className="flex-row items-center space-x-2">
|
||||
<View className={`h-3 w-3 rounded-full ${getHealthScoreColor(mealHealthScore)}`} />
|
||||
<Text className="text-sm text-gray-600">{mealHealthScore.toFixed(1)}/10</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="space-y-3">
|
||||
{/* Calories Header */}
|
||||
<View className="flex-row items-center justify-between">
|
||||
<Text className="text-2xl font-bold text-gray-900">
|
||||
{formatValue(mealCalories, ' kcal')}
|
||||
</Text>
|
||||
{mealHealthScore && (
|
||||
<View className="flex-row items-center space-x-2">
|
||||
<View className={`h-4 w-4 rounded-full ${getHealthScoreColor(mealHealthScore)}`} />
|
||||
<View className="items-end">
|
||||
<Text className="text-sm font-medium text-gray-900">
|
||||
{mealHealthScore.toFixed(1)}/10
|
||||
</Text>
|
||||
<Text className="text-xs text-gray-500">{getHealthScoreText(mealHealthScore)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
return (
|
||||
<View className="space-y-3">
|
||||
{/* Calories Header */}
|
||||
<View className="flex-row items-center justify-between">
|
||||
<Text className="text-2xl font-bold text-gray-900">
|
||||
{formatValue(mealCalories, ' kcal')}
|
||||
</Text>
|
||||
{mealHealthScore && (
|
||||
<View className="flex-row items-center space-x-2">
|
||||
<View className={`h-4 w-4 rounded-full ${getHealthScoreColor(mealHealthScore)}`} />
|
||||
<View className="items-end">
|
||||
<Text className="text-sm font-medium text-gray-900">
|
||||
{mealHealthScore.toFixed(1)}/10
|
||||
</Text>
|
||||
<Text className="text-xs text-gray-500">{getHealthScoreText(mealHealthScore)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Macronutrients */}
|
||||
<View className="flex-row justify-between">
|
||||
<View className="items-center">
|
||||
<Text className="text-lg font-semibold text-blue-600">{formatValue(mealProtein)}</Text>
|
||||
<Text className="text-xs uppercase tracking-wide text-gray-500">Protein</Text>
|
||||
</View>
|
||||
{/* Macronutrients */}
|
||||
<View className="flex-row justify-between">
|
||||
<View className="items-center">
|
||||
<Text className="text-lg font-semibold text-blue-600">{formatValue(mealProtein)}</Text>
|
||||
<Text className="text-xs uppercase tracking-wide text-gray-500">Protein</Text>
|
||||
</View>
|
||||
|
||||
<View className="items-center">
|
||||
<Text className="text-lg font-semibold text-green-600">{formatValue(mealCarbs)}</Text>
|
||||
<Text className="text-xs uppercase tracking-wide text-gray-500">Carbs</Text>
|
||||
</View>
|
||||
<View className="items-center">
|
||||
<Text className="text-lg font-semibold text-green-600">{formatValue(mealCarbs)}</Text>
|
||||
<Text className="text-xs uppercase tracking-wide text-gray-500">Carbs</Text>
|
||||
</View>
|
||||
|
||||
<View className="items-center">
|
||||
<Text className="text-lg font-semibold text-orange-600">{formatValue(mealFat)}</Text>
|
||||
<Text className="text-xs uppercase tracking-wide text-gray-500">Fat</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className="items-center">
|
||||
<Text className="text-lg font-semibold text-orange-600">{formatValue(mealFat)}</Text>
|
||||
<Text className="text-xs uppercase tracking-wide text-gray-500">Fat</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Additional nutrients for detailed view */}
|
||||
{showDetailed && (mealFiber || mealSugar) && (
|
||||
<View className="flex-row justify-between">
|
||||
{mealFiber && (
|
||||
<View className="items-center">
|
||||
<Text className="text-lg font-semibold text-purple-600">
|
||||
{formatValue(mealFiber)}
|
||||
</Text>
|
||||
<Text className="text-xs uppercase tracking-wide text-gray-500">Fiber</Text>
|
||||
</View>
|
||||
)}
|
||||
{mealSugar && (
|
||||
<View className="items-center">
|
||||
<Text className="text-lg font-semibold text-pink-600">{formatValue(mealSugar)}</Text>
|
||||
<Text className="text-xs uppercase tracking-wide text-gray-500">Sugar</Text>
|
||||
</View>
|
||||
)}
|
||||
<View className="items-center">
|
||||
<Text className="text-lg font-semibold text-transparent">--</Text>
|
||||
<Text className="text-xs uppercase tracking-wide text-transparent">--</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
{/* Additional nutrients for detailed view */}
|
||||
{showDetailed && (mealFiber || mealSugar) && (
|
||||
<View className="flex-row justify-between">
|
||||
{mealFiber && (
|
||||
<View className="items-center">
|
||||
<Text className="text-lg font-semibold text-purple-600">
|
||||
{formatValue(mealFiber)}
|
||||
</Text>
|
||||
<Text className="text-xs uppercase tracking-wide text-gray-500">Fiber</Text>
|
||||
</View>
|
||||
)}
|
||||
{mealSugar && (
|
||||
<View className="items-center">
|
||||
<Text className="text-lg font-semibold text-pink-600">{formatValue(mealSugar)}</Text>
|
||||
<Text className="text-xs uppercase tracking-wide text-gray-500">Sugar</Text>
|
||||
</View>
|
||||
)}
|
||||
<View className="items-center">
|
||||
<Text className="text-lg font-semibold text-transparent">--</Text>
|
||||
<Text className="text-xs uppercase tracking-wide text-transparent">--</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Visual Progress Bars */}
|
||||
<View className="space-y-2">
|
||||
<View className="flex-row items-center space-x-3">
|
||||
<Text className="w-12 text-xs text-gray-500">PROT</Text>
|
||||
<View className="h-2 flex-1 rounded-full bg-gray-200">
|
||||
<View
|
||||
className="h-2 rounded-full bg-blue-500"
|
||||
style={{ width: `${Math.min(((mealProtein || 0) / 50) * 100, 100)}%` }}
|
||||
/>
|
||||
</View>
|
||||
<Text className="w-8 text-xs text-gray-500">{formatValue(mealProtein)}</Text>
|
||||
</View>
|
||||
{/* Visual Progress Bars */}
|
||||
<View className="space-y-2">
|
||||
<View className="flex-row items-center space-x-3">
|
||||
<Text className="w-12 text-xs text-gray-500">PROT</Text>
|
||||
<View className="h-2 flex-1 rounded-full bg-gray-200">
|
||||
<View
|
||||
className="h-2 rounded-full bg-blue-500"
|
||||
style={{ width: `${Math.min(((mealProtein || 0) / 50) * 100, 100)}%` }}
|
||||
/>
|
||||
</View>
|
||||
<Text className="w-8 text-xs text-gray-500">{formatValue(mealProtein)}</Text>
|
||||
</View>
|
||||
|
||||
<View className="flex-row items-center space-x-3">
|
||||
<Text className="w-12 text-xs text-gray-500">CARB</Text>
|
||||
<View className="h-2 flex-1 rounded-full bg-gray-200">
|
||||
<View
|
||||
className="h-2 rounded-full bg-green-500"
|
||||
style={{ width: `${Math.min(((mealCarbs || 0) / 100) * 100, 100)}%` }}
|
||||
/>
|
||||
</View>
|
||||
<Text className="w-8 text-xs text-gray-500">{formatValue(mealCarbs)}</Text>
|
||||
</View>
|
||||
<View className="flex-row items-center space-x-3">
|
||||
<Text className="w-12 text-xs text-gray-500">CARB</Text>
|
||||
<View className="h-2 flex-1 rounded-full bg-gray-200">
|
||||
<View
|
||||
className="h-2 rounded-full bg-green-500"
|
||||
style={{ width: `${Math.min(((mealCarbs || 0) / 100) * 100, 100)}%` }}
|
||||
/>
|
||||
</View>
|
||||
<Text className="w-8 text-xs text-gray-500">{formatValue(mealCarbs)}</Text>
|
||||
</View>
|
||||
|
||||
<View className="flex-row items-center space-x-3">
|
||||
<Text className="w-12 text-xs text-gray-500">FAT</Text>
|
||||
<View className="h-2 flex-1 rounded-full bg-gray-200">
|
||||
<View
|
||||
className="h-2 rounded-full bg-orange-500"
|
||||
style={{ width: `${Math.min(((mealFat || 0) / 30) * 100, 100)}%` }}
|
||||
/>
|
||||
</View>
|
||||
<Text className="w-8 text-xs text-gray-500">{formatValue(mealFat)}</Text>
|
||||
</View>
|
||||
<View className="flex-row items-center space-x-3">
|
||||
<Text className="w-12 text-xs text-gray-500">FAT</Text>
|
||||
<View className="h-2 flex-1 rounded-full bg-gray-200">
|
||||
<View
|
||||
className="h-2 rounded-full bg-orange-500"
|
||||
style={{ width: `${Math.min(((mealFat || 0) / 30) * 100, 100)}%` }}
|
||||
/>
|
||||
</View>
|
||||
<Text className="w-8 text-xs text-gray-500">{formatValue(mealFat)}</Text>
|
||||
</View>
|
||||
|
||||
{/* Additional progress bars for detailed view */}
|
||||
{showDetailed && mealFiber && (
|
||||
<View className="flex-row items-center space-x-3">
|
||||
<Text className="w-12 text-xs text-gray-500">FIBER</Text>
|
||||
<View className="h-2 flex-1 rounded-full bg-gray-200">
|
||||
<View
|
||||
className="h-2 rounded-full bg-purple-500"
|
||||
style={{ width: `${Math.min(((mealFiber || 0) / 25) * 100, 100)}%` }}
|
||||
/>
|
||||
</View>
|
||||
<Text className="w-8 text-xs text-gray-500">{formatValue(mealFiber)}</Text>
|
||||
</View>
|
||||
)}
|
||||
{/* Additional progress bars for detailed view */}
|
||||
{showDetailed && mealFiber && (
|
||||
<View className="flex-row items-center space-x-3">
|
||||
<Text className="w-12 text-xs text-gray-500">FIBER</Text>
|
||||
<View className="h-2 flex-1 rounded-full bg-gray-200">
|
||||
<View
|
||||
className="h-2 rounded-full bg-purple-500"
|
||||
style={{ width: `${Math.min(((mealFiber || 0) / 25) * 100, 100)}%` }}
|
||||
/>
|
||||
</View>
|
||||
<Text className="w-8 text-xs text-gray-500">{formatValue(mealFiber)}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{showDetailed && mealSugar && (
|
||||
<View className="flex-row items-center space-x-3">
|
||||
<Text className="w-12 text-xs text-gray-500">SUGAR</Text>
|
||||
<View className="h-2 flex-1 rounded-full bg-gray-200">
|
||||
<View
|
||||
className="h-2 rounded-full bg-pink-500"
|
||||
style={{ width: `${Math.min(((mealSugar || 0) / 50) * 100, 100)}%` }}
|
||||
/>
|
||||
</View>
|
||||
<Text className="w-8 text-xs text-gray-500">{formatValue(mealSugar)}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
{showDetailed && mealSugar && (
|
||||
<View className="flex-row items-center space-x-3">
|
||||
<Text className="w-12 text-xs text-gray-500">SUGAR</Text>
|
||||
<View className="h-2 flex-1 rounded-full bg-gray-200">
|
||||
<View
|
||||
className="h-2 rounded-full bg-pink-500"
|
||||
style={{ width: `${Math.min(((mealSugar || 0) / 50) * 100, 100)}%` }}
|
||||
/>
|
||||
</View>
|
||||
<Text className="w-8 text-xs text-gray-500">{formatValue(mealSugar)}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,29 +2,29 @@ import React from 'react';
|
|||
import { View, ViewProps } from 'react-native';
|
||||
|
||||
interface CardProps extends ViewProps {
|
||||
variant?: 'default' | 'elevated' | 'outline';
|
||||
children: React.ReactNode;
|
||||
variant?: 'default' | 'elevated' | 'outline';
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const Card: React.FC<CardProps> = ({
|
||||
variant = 'default',
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
variant = 'default',
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
const baseStyles = 'rounded-xl p-4 bg-white';
|
||||
const baseStyles = 'rounded-xl p-4 bg-white';
|
||||
|
||||
const variantStyles = {
|
||||
default: 'border border-gray-100',
|
||||
elevated: 'shadow-lg shadow-gray-200',
|
||||
outline: 'border-2 border-gray-200',
|
||||
};
|
||||
const variantStyles = {
|
||||
default: 'border border-gray-100',
|
||||
elevated: 'shadow-lg shadow-gray-200',
|
||||
outline: 'border-2 border-gray-200',
|
||||
};
|
||||
|
||||
const combinedClassName = `${baseStyles} ${variantStyles[variant]} ${className || ''}`;
|
||||
const combinedClassName = `${baseStyles} ${variantStyles[variant]} ${className || ''}`;
|
||||
|
||||
return (
|
||||
<View className={combinedClassName} {...props}>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
return (
|
||||
<View className={combinedClassName} {...props}>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,154 +1,156 @@
|
|||
import React from 'react';
|
||||
import { TouchableOpacity, Text, View } from 'react-native';
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withSpring,
|
||||
interpolate,
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withSpring,
|
||||
interpolate,
|
||||
} from 'react-native-reanimated';
|
||||
import { SFSymbol } from './SFSymbol';
|
||||
|
||||
interface FloatingActionButtonProps {
|
||||
onPress: () => void;
|
||||
icon?: string;
|
||||
sfSymbol?: string;
|
||||
fallbackIcon?: string;
|
||||
disabled?: boolean;
|
||||
size?: 'normal' | 'large';
|
||||
position?: 'right' | 'center' | 'left';
|
||||
onPress: () => void;
|
||||
icon?: string;
|
||||
sfSymbol?: string;
|
||||
fallbackIcon?: string;
|
||||
disabled?: boolean;
|
||||
size?: 'normal' | 'large';
|
||||
position?: 'right' | 'center' | 'left';
|
||||
}
|
||||
|
||||
const AnimatedTouchableOpacity = Animated.createAnimatedComponent(TouchableOpacity);
|
||||
|
||||
export const FloatingActionButton: React.FC<FloatingActionButtonProps> = ({
|
||||
onPress,
|
||||
icon = '+',
|
||||
sfSymbol,
|
||||
fallbackIcon,
|
||||
disabled = false,
|
||||
size = 'normal',
|
||||
position = 'right',
|
||||
onPress,
|
||||
icon = '+',
|
||||
sfSymbol,
|
||||
fallbackIcon,
|
||||
disabled = false,
|
||||
size = 'normal',
|
||||
position = 'right',
|
||||
}) => {
|
||||
const pressed = useSharedValue(false);
|
||||
const pressed = useSharedValue(false);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
const scale = interpolate(pressed.value ? 1 : 0, [0, 1], [1, 0.95]);
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
const scale = interpolate(pressed.value ? 1 : 0, [0, 1], [1, 0.95]);
|
||||
|
||||
return {
|
||||
transform: [{ scale: withSpring(scale) }],
|
||||
};
|
||||
});
|
||||
return {
|
||||
transform: [{ scale: withSpring(scale) }],
|
||||
};
|
||||
});
|
||||
|
||||
const handlePressIn = () => {
|
||||
pressed.value = true;
|
||||
};
|
||||
const handlePressIn = () => {
|
||||
pressed.value = true;
|
||||
};
|
||||
|
||||
const handlePressOut = () => {
|
||||
pressed.value = false;
|
||||
};
|
||||
const handlePressOut = () => {
|
||||
pressed.value = false;
|
||||
};
|
||||
|
||||
const getContainerStyle = () => {
|
||||
const base = { position: 'absolute' as const, bottom: 24, zIndex: 50 };
|
||||
switch (position) {
|
||||
case 'center':
|
||||
return {
|
||||
...base,
|
||||
width: '100%',
|
||||
alignItems: 'center' as const,
|
||||
};
|
||||
case 'left':
|
||||
return { ...base, left: 24 };
|
||||
case 'right':
|
||||
default:
|
||||
return { ...base, right: 24 };
|
||||
}
|
||||
};
|
||||
const getContainerStyle = () => {
|
||||
const base = { position: 'absolute' as const, bottom: 24, zIndex: 50 };
|
||||
switch (position) {
|
||||
case 'center':
|
||||
return {
|
||||
...base,
|
||||
width: '100%',
|
||||
alignItems: 'center' as const,
|
||||
};
|
||||
case 'left':
|
||||
return { ...base, left: 24 };
|
||||
case 'right':
|
||||
default:
|
||||
return { ...base, right: 24 };
|
||||
}
|
||||
};
|
||||
|
||||
const getSizeStyle = () => {
|
||||
switch (size) {
|
||||
case 'large':
|
||||
return { width: 80, height: 80 };
|
||||
case 'normal':
|
||||
default:
|
||||
return { width: 64, height: 64 };
|
||||
}
|
||||
};
|
||||
const getSizeStyle = () => {
|
||||
switch (size) {
|
||||
case 'large':
|
||||
return { width: 80, height: 80 };
|
||||
case 'normal':
|
||||
default:
|
||||
return { width: 64, height: 64 };
|
||||
}
|
||||
};
|
||||
|
||||
const getIconSize = () => {
|
||||
switch (size) {
|
||||
case 'large':
|
||||
return 32;
|
||||
case 'normal':
|
||||
default:
|
||||
return 24;
|
||||
}
|
||||
};
|
||||
const getIconSize = () => {
|
||||
switch (size) {
|
||||
case 'large':
|
||||
return 32;
|
||||
case 'normal':
|
||||
default:
|
||||
return 24;
|
||||
}
|
||||
};
|
||||
|
||||
const getTextSize = () => {
|
||||
switch (size) {
|
||||
case 'large':
|
||||
return 'text-3xl';
|
||||
case 'normal':
|
||||
default:
|
||||
return 'text-2xl';
|
||||
}
|
||||
};
|
||||
const getTextSize = () => {
|
||||
switch (size) {
|
||||
case 'large':
|
||||
return 'text-3xl';
|
||||
case 'normal':
|
||||
default:
|
||||
return 'text-2xl';
|
||||
}
|
||||
};
|
||||
|
||||
const renderIcon = () => {
|
||||
if (sfSymbol && fallbackIcon) {
|
||||
return (
|
||||
<SFSymbol
|
||||
name={sfSymbol}
|
||||
fallbackIcon={fallbackIcon as any}
|
||||
size={getIconSize()}
|
||||
color="white"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <Text className={`${getTextSize()} font-light text-white`}>{icon}</Text>;
|
||||
};
|
||||
const renderIcon = () => {
|
||||
if (sfSymbol && fallbackIcon) {
|
||||
return (
|
||||
<SFSymbol
|
||||
name={sfSymbol}
|
||||
fallbackIcon={fallbackIcon as any}
|
||||
size={getIconSize()}
|
||||
color="white"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <Text className={`${getTextSize()} font-light text-white`}>{icon}</Text>;
|
||||
};
|
||||
|
||||
const combinedStyle = [
|
||||
animatedStyle,
|
||||
getSizeStyle(),
|
||||
position === 'center' ? {} : getContainerStyle(),
|
||||
];
|
||||
const combinedStyle = [
|
||||
animatedStyle,
|
||||
getSizeStyle(),
|
||||
position === 'center' ? {} : getContainerStyle(),
|
||||
];
|
||||
|
||||
const containerStyle = position === 'center' ? getContainerStyle() : {};
|
||||
const containerStyle = position === 'center' ? getContainerStyle() : {};
|
||||
|
||||
if (position === 'center') {
|
||||
return (
|
||||
<View style={containerStyle}>
|
||||
<AnimatedTouchableOpacity
|
||||
onPress={onPress}
|
||||
onPressIn={handlePressIn}
|
||||
onPressOut={handlePressOut}
|
||||
disabled={disabled}
|
||||
activeOpacity={0.8}
|
||||
style={[animatedStyle, getSizeStyle()]}
|
||||
className={`
|
||||
if (position === 'center') {
|
||||
return (
|
||||
<View style={containerStyle}>
|
||||
<AnimatedTouchableOpacity
|
||||
onPress={onPress}
|
||||
onPressIn={handlePressIn}
|
||||
onPressOut={handlePressOut}
|
||||
disabled={disabled}
|
||||
activeOpacity={0.8}
|
||||
style={[animatedStyle, getSizeStyle()]}
|
||||
className={`
|
||||
items-center justify-center rounded-full shadow-lg
|
||||
${disabled ? 'bg-gray-400' : 'bg-indigo-500'}
|
||||
`}>
|
||||
{renderIcon()}
|
||||
</AnimatedTouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
`}
|
||||
>
|
||||
{renderIcon()}
|
||||
</AnimatedTouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatedTouchableOpacity
|
||||
onPress={onPress}
|
||||
onPressIn={handlePressIn}
|
||||
onPressOut={handlePressOut}
|
||||
disabled={disabled}
|
||||
activeOpacity={0.8}
|
||||
style={combinedStyle}
|
||||
className={`
|
||||
return (
|
||||
<AnimatedTouchableOpacity
|
||||
onPress={onPress}
|
||||
onPressIn={handlePressIn}
|
||||
onPressOut={handlePressOut}
|
||||
disabled={disabled}
|
||||
activeOpacity={0.8}
|
||||
style={combinedStyle}
|
||||
className={`
|
||||
items-center justify-center rounded-full shadow-lg
|
||||
${disabled ? 'bg-gray-400' : 'bg-indigo-500'}
|
||||
`}>
|
||||
{renderIcon()}
|
||||
</AnimatedTouchableOpacity>
|
||||
);
|
||||
`}
|
||||
>
|
||||
{renderIcon()}
|
||||
</AnimatedTouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,29 +4,30 @@ import { router } from 'expo-router';
|
|||
import { SFSymbol } from './SFSymbol';
|
||||
|
||||
interface HeaderProps {
|
||||
title: string;
|
||||
onSettingsPress?: () => void;
|
||||
title: string;
|
||||
onSettingsPress?: () => void;
|
||||
}
|
||||
|
||||
export const Header: React.FC<HeaderProps> = ({ title, onSettingsPress }) => {
|
||||
const handleSettingsPress = () => {
|
||||
if (onSettingsPress) {
|
||||
onSettingsPress();
|
||||
} else {
|
||||
router.push('/settings');
|
||||
}
|
||||
};
|
||||
const handleSettingsPress = () => {
|
||||
if (onSettingsPress) {
|
||||
onSettingsPress();
|
||||
} else {
|
||||
router.push('/settings');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View className="flex-row items-center justify-between px-4 py-3">
|
||||
<Text className="text-2xl font-bold text-gray-900 dark:text-white">{title}</Text>
|
||||
<TouchableOpacity
|
||||
onPress={handleSettingsPress}
|
||||
className="p-2"
|
||||
accessibilityLabel="Settings"
|
||||
accessibilityRole="button">
|
||||
<SFSymbol name="gearshape" fallbackIcon="cog" size={24} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
return (
|
||||
<View className="flex-row items-center justify-between px-4 py-3">
|
||||
<Text className="text-2xl font-bold text-gray-900 dark:text-white">{title}</Text>
|
||||
<TouchableOpacity
|
||||
onPress={handleSettingsPress}
|
||||
className="p-2"
|
||||
accessibilityLabel="Settings"
|
||||
accessibilityRole="button"
|
||||
>
|
||||
<SFSymbol name="gearshape" fallbackIcon="cog" size={24} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,30 +2,30 @@ import React from 'react';
|
|||
import { View, Text, Modal, ActivityIndicator } from 'react-native';
|
||||
|
||||
interface LoadingOverlayProps {
|
||||
visible: boolean;
|
||||
message?: string;
|
||||
backgroundColor?: string;
|
||||
visible: boolean;
|
||||
message?: string;
|
||||
backgroundColor?: string;
|
||||
}
|
||||
|
||||
export default function LoadingOverlay({
|
||||
visible,
|
||||
message = 'Wird geladen...',
|
||||
backgroundColor = 'rgba(0, 0, 0, 0.7)',
|
||||
visible,
|
||||
message = 'Wird geladen...',
|
||||
backgroundColor = 'rgba(0, 0, 0, 0.7)',
|
||||
}: LoadingOverlayProps) {
|
||||
if (!visible) return null;
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<Modal transparent visible={visible} animationType="fade">
|
||||
<View className="flex-1 items-center justify-center" style={{ backgroundColor }}>
|
||||
<View className="rounded-2xl bg-white p-8 shadow-lg dark:bg-gray-800">
|
||||
<View className="items-center space-y-4">
|
||||
<ActivityIndicator size="large" className="text-indigo-500" />
|
||||
<Text className="text-center text-lg font-medium text-gray-900 dark:text-white">
|
||||
{message}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
return (
|
||||
<Modal transparent visible={visible} animationType="fade">
|
||||
<View className="flex-1 items-center justify-center" style={{ backgroundColor }}>
|
||||
<View className="rounded-2xl bg-white p-8 shadow-lg dark:bg-gray-800">
|
||||
<View className="items-center space-y-4">
|
||||
<ActivityIndicator size="large" className="text-indigo-500" />
|
||||
<Text className="text-center text-lg font-medium text-gray-900 dark:text-white">
|
||||
{message}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,31 +2,31 @@ import React from 'react';
|
|||
import { ActivityIndicator, View, Text } from 'react-native';
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
size?: 'small' | 'large';
|
||||
color?: string;
|
||||
text?: string;
|
||||
overlay?: boolean;
|
||||
size?: 'small' | 'large';
|
||||
color?: string;
|
||||
text?: string;
|
||||
overlay?: boolean;
|
||||
}
|
||||
|
||||
export const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
|
||||
size = 'large',
|
||||
color = '#6366f1',
|
||||
text,
|
||||
overlay = false,
|
||||
size = 'large',
|
||||
color = '#6366f1',
|
||||
text,
|
||||
overlay = false,
|
||||
}) => {
|
||||
const Container = overlay ? View : React.Fragment;
|
||||
const containerProps = overlay
|
||||
? {
|
||||
className: 'absolute inset-0 bg-black/20 flex-1 justify-center items-center z-50',
|
||||
}
|
||||
: {};
|
||||
const Container = overlay ? View : React.Fragment;
|
||||
const containerProps = overlay
|
||||
? {
|
||||
className: 'absolute inset-0 bg-black/20 flex-1 justify-center items-center z-50',
|
||||
}
|
||||
: {};
|
||||
|
||||
return (
|
||||
<Container {...containerProps}>
|
||||
<View className="items-center space-y-2">
|
||||
<ActivityIndicator size={size} color={color} />
|
||||
{text && <Text className="text-sm text-gray-600">{text}</Text>}
|
||||
</View>
|
||||
</Container>
|
||||
);
|
||||
return (
|
||||
<Container {...containerProps}>
|
||||
<View className="items-center space-y-2">
|
||||
<ActivityIndicator size={size} color={color} />
|
||||
{text && <Text className="text-sm text-gray-600">{text}</Text>}
|
||||
</View>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,50 +5,50 @@ import FontAwesome from '@expo/vector-icons/FontAwesome';
|
|||
import { useColorScheme } from 'nativewind';
|
||||
|
||||
interface SFSymbolProps {
|
||||
name: string;
|
||||
size?: number;
|
||||
color?: string;
|
||||
weight?: SymbolViewProps['weight'];
|
||||
scale?: SymbolViewProps['scale'];
|
||||
mode?: SymbolViewProps['mode'];
|
||||
fallbackIcon?: React.ComponentProps<typeof FontAwesome>['name'];
|
||||
style?: SymbolViewProps['style'];
|
||||
name: string;
|
||||
size?: number;
|
||||
color?: string;
|
||||
weight?: SymbolViewProps['weight'];
|
||||
scale?: SymbolViewProps['scale'];
|
||||
mode?: SymbolViewProps['mode'];
|
||||
fallbackIcon?: React.ComponentProps<typeof FontAwesome>['name'];
|
||||
style?: SymbolViewProps['style'];
|
||||
}
|
||||
|
||||
export const SFSymbol: React.FC<SFSymbolProps> = ({
|
||||
name,
|
||||
size = 24,
|
||||
color,
|
||||
weight = 'regular',
|
||||
scale = 'default',
|
||||
mode = 'monochrome',
|
||||
fallbackIcon,
|
||||
style,
|
||||
name,
|
||||
size = 24,
|
||||
color,
|
||||
weight = 'regular',
|
||||
scale = 'default',
|
||||
mode = 'monochrome',
|
||||
fallbackIcon,
|
||||
style,
|
||||
}) => {
|
||||
const { colorScheme } = useColorScheme();
|
||||
const { colorScheme } = useColorScheme();
|
||||
|
||||
// Use dynamic color if no color specified
|
||||
const dynamicColor = color || (colorScheme === 'dark' ? '#ffffff' : '#374151');
|
||||
// Use SF Symbols on iOS, fallback to FontAwesome on Android
|
||||
if (Platform.OS === 'ios') {
|
||||
return (
|
||||
<SymbolView
|
||||
name={name}
|
||||
size={size}
|
||||
tintColor={dynamicColor}
|
||||
weight={weight}
|
||||
scale={scale}
|
||||
mode={mode}
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
}
|
||||
// Use dynamic color if no color specified
|
||||
const dynamicColor = color || (colorScheme === 'dark' ? '#ffffff' : '#374151');
|
||||
// Use SF Symbols on iOS, fallback to FontAwesome on Android
|
||||
if (Platform.OS === 'ios') {
|
||||
return (
|
||||
<SymbolView
|
||||
name={name}
|
||||
size={size}
|
||||
tintColor={dynamicColor}
|
||||
weight={weight}
|
||||
scale={scale}
|
||||
mode={mode}
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Android fallback
|
||||
if (fallbackIcon) {
|
||||
return <FontAwesome name={fallbackIcon} size={size} color={dynamicColor} style={style} />;
|
||||
}
|
||||
// Android fallback
|
||||
if (fallbackIcon) {
|
||||
return <FontAwesome name={fallbackIcon} size={size} color={dynamicColor} style={style} />;
|
||||
}
|
||||
|
||||
// Default fallback if no fallbackIcon provided
|
||||
return <FontAwesome name="question-circle" size={size} color={dynamicColor} style={style} />;
|
||||
// Default fallback if no fallbackIcon provided
|
||||
return <FontAwesome name="question-circle" size={size} color={dynamicColor} style={style} />;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,21 +1,21 @@
|
|||
{
|
||||
"cli": {
|
||||
"version": ">= 16.9.0",
|
||||
"appVersionSource": "remote"
|
||||
},
|
||||
"build": {
|
||||
"development": {
|
||||
"developmentClient": true,
|
||||
"distribution": "internal"
|
||||
},
|
||||
"preview": {
|
||||
"distribution": "internal"
|
||||
},
|
||||
"production": {
|
||||
"autoIncrement": true
|
||||
}
|
||||
},
|
||||
"submit": {
|
||||
"production": {}
|
||||
}
|
||||
"cli": {
|
||||
"version": ">= 16.9.0",
|
||||
"appVersionSource": "remote"
|
||||
},
|
||||
"build": {
|
||||
"development": {
|
||||
"developmentClient": true,
|
||||
"distribution": "internal"
|
||||
},
|
||||
"preview": {
|
||||
"distribution": "internal"
|
||||
},
|
||||
"production": {
|
||||
"autoIncrement": true
|
||||
}
|
||||
},
|
||||
"submit": {
|
||||
"production": {}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,13 +3,13 @@ const { defineConfig } = require('eslint/config');
|
|||
const expoConfig = require('eslint-config-expo/flat');
|
||||
|
||||
module.exports = defineConfig([
|
||||
expoConfig,
|
||||
{
|
||||
ignores: ['dist/*'],
|
||||
},
|
||||
{
|
||||
rules: {
|
||||
'react/display-name': 'off',
|
||||
},
|
||||
},
|
||||
expoConfig,
|
||||
{
|
||||
ignores: ['dist/*'],
|
||||
},
|
||||
{
|
||||
rules: {
|
||||
'react/display-name': 'off',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -4,102 +4,102 @@ import * as ImagePicker from 'expo-image-picker';
|
|||
import { PhotoService } from '../services/storage/PhotoService';
|
||||
|
||||
export function useCamera() {
|
||||
const [permission, requestPermission] = useCameraPermissions();
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [isCapturing, setIsCapturing] = useState(false);
|
||||
const [facing, setFacing] = useState<CameraType>('back');
|
||||
const cameraRef = useRef<CameraView>(null);
|
||||
const [permission, requestPermission] = useCameraPermissions();
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [isCapturing, setIsCapturing] = useState(false);
|
||||
const [facing, setFacing] = useState<CameraType>('back');
|
||||
const cameraRef = useRef<CameraView>(null);
|
||||
|
||||
const photoService = PhotoService.getInstance();
|
||||
const photoService = PhotoService.getInstance();
|
||||
|
||||
const toggleCameraFacing = () => {
|
||||
setFacing((current) => (current === 'back' ? 'front' : 'back'));
|
||||
};
|
||||
const toggleCameraFacing = () => {
|
||||
setFacing((current) => (current === 'back' ? 'front' : 'back'));
|
||||
};
|
||||
|
||||
const takePicture = async () => {
|
||||
if (!cameraRef.current || isCapturing) return null;
|
||||
const takePicture = async () => {
|
||||
if (!cameraRef.current || isCapturing) return null;
|
||||
|
||||
try {
|
||||
setIsCapturing(true);
|
||||
try {
|
||||
setIsCapturing(true);
|
||||
|
||||
const photo = await cameraRef.current.takePictureAsync({
|
||||
quality: 0.8,
|
||||
base64: false,
|
||||
exif: false,
|
||||
});
|
||||
const photo = await cameraRef.current.takePictureAsync({
|
||||
quality: 0.8,
|
||||
base64: false,
|
||||
exif: false,
|
||||
});
|
||||
|
||||
if (!photo) return null;
|
||||
if (!photo) return null;
|
||||
|
||||
// Save photo using PhotoService
|
||||
const savedPhoto = await photoService.savePhoto(photo.uri);
|
||||
// Save photo using PhotoService
|
||||
const savedPhoto = await photoService.savePhoto(photo.uri);
|
||||
|
||||
return {
|
||||
uri: photo.uri,
|
||||
...savedPhoto,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to take picture:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsCapturing(false);
|
||||
}
|
||||
};
|
||||
return {
|
||||
uri: photo.uri,
|
||||
...savedPhoto,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to take picture:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsCapturing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const pickImageFromGallery = async () => {
|
||||
try {
|
||||
// Request permission
|
||||
const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||
const pickImageFromGallery = async () => {
|
||||
try {
|
||||
// Request permission
|
||||
const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||
|
||||
if (!permissionResult.granted) {
|
||||
throw new Error('Permission to access gallery denied');
|
||||
}
|
||||
if (!permissionResult.granted) {
|
||||
throw new Error('Permission to access gallery denied');
|
||||
}
|
||||
|
||||
// Launch image picker
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: ['images'],
|
||||
allowsEditing: true,
|
||||
aspect: [4, 3],
|
||||
quality: 0.8,
|
||||
});
|
||||
// Launch image picker
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: ['images'],
|
||||
allowsEditing: true,
|
||||
aspect: [4, 3],
|
||||
quality: 0.8,
|
||||
});
|
||||
|
||||
if (result.canceled) {
|
||||
return null;
|
||||
}
|
||||
if (result.canceled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const asset = result.assets[0];
|
||||
const asset = result.assets[0];
|
||||
|
||||
// Save photo using PhotoService
|
||||
const savedPhoto = await photoService.savePhoto(asset.uri);
|
||||
// Save photo using PhotoService
|
||||
const savedPhoto = await photoService.savePhoto(asset.uri);
|
||||
|
||||
return {
|
||||
uri: asset.uri,
|
||||
...savedPhoto,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to pick image from gallery:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
return {
|
||||
uri: asset.uri,
|
||||
...savedPhoto,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to pick image from gallery:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const hasPermission = permission?.granted ?? false;
|
||||
const canAskPermission = permission?.canAskAgain ?? true;
|
||||
const hasPermission = permission?.granted ?? false;
|
||||
const canAskPermission = permission?.canAskAgain ?? true;
|
||||
|
||||
return {
|
||||
// Permission state
|
||||
hasPermission,
|
||||
canAskPermission,
|
||||
requestPermission,
|
||||
return {
|
||||
// Permission state
|
||||
hasPermission,
|
||||
canAskPermission,
|
||||
requestPermission,
|
||||
|
||||
// Camera state
|
||||
isReady,
|
||||
setIsReady,
|
||||
isCapturing,
|
||||
facing,
|
||||
cameraRef,
|
||||
// Camera state
|
||||
isReady,
|
||||
setIsReady,
|
||||
isCapturing,
|
||||
facing,
|
||||
cameraRef,
|
||||
|
||||
// Actions
|
||||
toggleCameraFacing,
|
||||
takePicture,
|
||||
pickImageFromGallery,
|
||||
};
|
||||
// Actions
|
||||
toggleCameraFacing,
|
||||
takePicture,
|
||||
pickImageFromGallery,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,71 +6,71 @@ import { UserPreferencesService } from '../services/UserPreferencesService';
|
|||
import { useAppStore } from '../store/AppStore';
|
||||
|
||||
export function useDatabase() {
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const setInitialized = useAppStore((state) => state.setInitialized);
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const setInitialized = useAppStore((state) => state.setInitialized);
|
||||
|
||||
useEffect(() => {
|
||||
initializeDatabase();
|
||||
}, [initializeDatabase]);
|
||||
useEffect(() => {
|
||||
initializeDatabase();
|
||||
}, [initializeDatabase]);
|
||||
|
||||
const initializeDatabase = useCallback(async () => {
|
||||
try {
|
||||
console.log('Initializing database...');
|
||||
const initializeDatabase = useCallback(async () => {
|
||||
try {
|
||||
console.log('Initializing database...');
|
||||
|
||||
// Initialize SQLite service
|
||||
const dbService = SQLiteService.getInstance();
|
||||
await dbService.initialize();
|
||||
// Initialize SQLite service
|
||||
const dbService = SQLiteService.getInstance();
|
||||
await dbService.initialize();
|
||||
|
||||
// Get database instance for migrations
|
||||
const db = (dbService as any).db; // Access private db property
|
||||
if (db) {
|
||||
const migrationService = MigrationService.getInstance();
|
||||
migrationService.setDatabase(db);
|
||||
await migrationService.runMigrations();
|
||||
}
|
||||
// Get database instance for migrations
|
||||
const db = (dbService as any).db; // Access private db property
|
||||
if (db) {
|
||||
const migrationService = MigrationService.getInstance();
|
||||
migrationService.setDatabase(db);
|
||||
await migrationService.runMigrations();
|
||||
}
|
||||
|
||||
console.log('Database initialized successfully');
|
||||
console.log('Database initialized successfully');
|
||||
|
||||
// Initialize user preferences
|
||||
const prefsService = UserPreferencesService.getInstance();
|
||||
await prefsService.initialize();
|
||||
console.log('User preferences initialized');
|
||||
// Initialize user preferences
|
||||
const prefsService = UserPreferencesService.getInstance();
|
||||
await prefsService.initialize();
|
||||
console.log('User preferences initialized');
|
||||
|
||||
// Clean up temporary photos on app start
|
||||
const photoService = PhotoService.getInstance();
|
||||
await photoService.cleanupTempPhotos();
|
||||
console.log('Temporary photos cleaned up');
|
||||
// Clean up temporary photos on app start
|
||||
const photoService = PhotoService.getInstance();
|
||||
await photoService.cleanupTempPhotos();
|
||||
console.log('Temporary photos cleaned up');
|
||||
|
||||
setIsReady(true);
|
||||
setInitialized(true);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Database initialization failed';
|
||||
console.error('Database initialization error:', errorMessage);
|
||||
setError(errorMessage);
|
||||
}
|
||||
}, [setInitialized]);
|
||||
setIsReady(true);
|
||||
setInitialized(true);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Database initialization failed';
|
||||
console.error('Database initialization error:', errorMessage);
|
||||
setError(errorMessage);
|
||||
}
|
||||
}, [setInitialized]);
|
||||
|
||||
const resetDatabase = async () => {
|
||||
try {
|
||||
setIsReady(false);
|
||||
setError(null);
|
||||
const resetDatabase = async () => {
|
||||
try {
|
||||
setIsReady(false);
|
||||
setError(null);
|
||||
|
||||
const dbService = SQLiteService.getInstance();
|
||||
await dbService.close();
|
||||
const dbService = SQLiteService.getInstance();
|
||||
await dbService.close();
|
||||
|
||||
// Reinitialize
|
||||
await initializeDatabase();
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Database reset failed';
|
||||
setError(errorMessage);
|
||||
}
|
||||
};
|
||||
// Reinitialize
|
||||
await initializeDatabase();
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Database reset failed';
|
||||
setError(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
isReady,
|
||||
error,
|
||||
resetDatabase,
|
||||
retryInitialization: initializeDatabase,
|
||||
};
|
||||
return {
|
||||
isReady,
|
||||
error,
|
||||
resetDatabase,
|
||||
retryInitialization: initializeDatabase,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,57 +6,57 @@ import AsyncStorage from '@react-native-async-storage/async-storage';
|
|||
const THEME_STORAGE_KEY = 'user-theme-preference';
|
||||
|
||||
export const useTheme = () => {
|
||||
const { colorScheme, setColorScheme } = useColorScheme();
|
||||
const { theme, setTheme } = useAppStore();
|
||||
const { colorScheme, setColorScheme } = useColorScheme();
|
||||
const { theme, setTheme } = useAppStore();
|
||||
|
||||
// Initialize theme from storage on app start
|
||||
useEffect(() => {
|
||||
const initializeTheme = async () => {
|
||||
try {
|
||||
const storedTheme = await AsyncStorage.getItem(THEME_STORAGE_KEY);
|
||||
if (storedTheme && ['light', 'dark', 'system'].includes(storedTheme)) {
|
||||
const parsedTheme = storedTheme as 'light' | 'dark' | 'system';
|
||||
setTheme(parsedTheme);
|
||||
// Initialize theme from storage on app start
|
||||
useEffect(() => {
|
||||
const initializeTheme = async () => {
|
||||
try {
|
||||
const storedTheme = await AsyncStorage.getItem(THEME_STORAGE_KEY);
|
||||
if (storedTheme && ['light', 'dark', 'system'].includes(storedTheme)) {
|
||||
const parsedTheme = storedTheme as 'light' | 'dark' | 'system';
|
||||
setTheme(parsedTheme);
|
||||
|
||||
// Apply the theme to NativeWind
|
||||
if (parsedTheme === 'system') {
|
||||
setColorScheme('system');
|
||||
} else {
|
||||
setColorScheme(parsedTheme);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading theme from storage:', error);
|
||||
}
|
||||
};
|
||||
// Apply the theme to NativeWind
|
||||
if (parsedTheme === 'system') {
|
||||
setColorScheme('system');
|
||||
} else {
|
||||
setColorScheme(parsedTheme);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading theme from storage:', error);
|
||||
}
|
||||
};
|
||||
|
||||
initializeTheme();
|
||||
}, [setTheme, setColorScheme]);
|
||||
initializeTheme();
|
||||
}, [setTheme, setColorScheme]);
|
||||
|
||||
const updateTheme = async (newTheme: 'light' | 'dark' | 'system') => {
|
||||
try {
|
||||
// Update AppStore
|
||||
setTheme(newTheme);
|
||||
const updateTheme = async (newTheme: 'light' | 'dark' | 'system') => {
|
||||
try {
|
||||
// Update AppStore
|
||||
setTheme(newTheme);
|
||||
|
||||
// Update NativeWind
|
||||
if (newTheme === 'system') {
|
||||
setColorScheme('system');
|
||||
} else {
|
||||
setColorScheme(newTheme);
|
||||
}
|
||||
// Update NativeWind
|
||||
if (newTheme === 'system') {
|
||||
setColorScheme('system');
|
||||
} else {
|
||||
setColorScheme(newTheme);
|
||||
}
|
||||
|
||||
// Persist to storage
|
||||
await AsyncStorage.setItem(THEME_STORAGE_KEY, newTheme);
|
||||
} catch (error) {
|
||||
console.error('Error saving theme to storage:', error);
|
||||
}
|
||||
};
|
||||
// Persist to storage
|
||||
await AsyncStorage.setItem(THEME_STORAGE_KEY, newTheme);
|
||||
} catch (error) {
|
||||
console.error('Error saving theme to storage:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
theme,
|
||||
colorScheme,
|
||||
updateTheme,
|
||||
isDark: colorScheme === 'dark',
|
||||
isLight: colorScheme === 'light',
|
||||
};
|
||||
return {
|
||||
theme,
|
||||
colorScheme,
|
||||
updateTheme,
|
||||
isDark: colorScheme === 'dark',
|
||||
isLight: colorScheme === 'light',
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -10,8 +10,8 @@ const config = getDefaultConfig(__dirname);
|
|||
|
||||
// Add path mapping for @ alias
|
||||
config.resolver.alias = {
|
||||
'@': path.resolve(__dirname, './'),
|
||||
...config.resolver.alias,
|
||||
'@': path.resolve(__dirname, './'),
|
||||
...config.resolver.alias,
|
||||
};
|
||||
|
||||
module.exports = withNativeWind(config, { input: './global.css' });
|
||||
|
|
|
|||
|
|
@ -1,70 +1,70 @@
|
|||
{
|
||||
"name": "@nutriphi/mobile",
|
||||
"version": "1.0.0",
|
||||
"main": "expo-router/entry",
|
||||
"scripts": {
|
||||
"dev": "expo start --dev-client",
|
||||
"start": "expo start --dev-client",
|
||||
"ios": "expo run:ios",
|
||||
"android": "expo run:android",
|
||||
"build:dev": "eas build --profile development",
|
||||
"build:preview": "eas build --profile preview",
|
||||
"build:prod": "eas build --profile production",
|
||||
"prebuild": "expo prebuild",
|
||||
"lint": "eslint \"**/*.{js,jsx,ts,tsx}\" && prettier -c \"**/*.{js,jsx,ts,tsx,json}\"",
|
||||
"format": "eslint \"**/*.{js,jsx,ts,tsx}\" --fix && prettier \"**/*.{js,jsx,ts,tsx,json}\" --write",
|
||||
"web": "expo start --web",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/vector-icons": "^14.0.0",
|
||||
"@google/generative-ai": "^0.24.1",
|
||||
"@react-native-async-storage/async-storage": "2.1.2",
|
||||
"@react-native-clipboard/clipboard": "^1.16.2",
|
||||
"@react-navigation/native": "^7.0.3",
|
||||
"expo": "^53.0.11",
|
||||
"expo-application": "~6.1.4",
|
||||
"expo-device": "~7.1.4",
|
||||
"expo-secure-store": "~14.2.3",
|
||||
"expo-blur": "^14.1.5",
|
||||
"expo-camera": "^16.1.8",
|
||||
"expo-constants": "~17.1.4",
|
||||
"expo-dev-client": "~5.2.0",
|
||||
"expo-dev-launcher": "^5.0.17",
|
||||
"expo-file-system": "^18.1.10",
|
||||
"expo-image-picker": "^16.1.4",
|
||||
"expo-linking": "~7.1.4",
|
||||
"expo-location": "^18.1.5",
|
||||
"expo-router": "~5.1.0",
|
||||
"expo-sqlite": "^15.2.12",
|
||||
"expo-status-bar": "~2.2.3",
|
||||
"expo-symbols": "~0.4.5",
|
||||
"expo-system-ui": "~5.0.6",
|
||||
"expo-web-browser": "~14.1.6",
|
||||
"nativewind": "latest",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0",
|
||||
"react-native": "0.79.3",
|
||||
"react-native-context-menu-view": "^1.19.0",
|
||||
"react-native-gesture-handler": "~2.24.0",
|
||||
"react-native-reanimated": "~3.17.4",
|
||||
"react-native-safe-area-context": "5.4.0",
|
||||
"react-native-screens": "~4.11.1",
|
||||
"react-native-uuid": "^2.0.3",
|
||||
"react-native-web": "^0.20.0",
|
||||
"zustand": "^4.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.20.0",
|
||||
"@types/react": "~19.0.10",
|
||||
"ajv": "^8.12.0",
|
||||
"eslint": "^9.25.1",
|
||||
"eslint-config-expo": "^9.2.0",
|
||||
"eslint-config-prettier": "^10.1.2",
|
||||
"prettier": "^3.2.5",
|
||||
"prettier-plugin-tailwindcss": "^0.5.11",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "~5.8.3"
|
||||
},
|
||||
"private": true
|
||||
"name": "@nutriphi/mobile",
|
||||
"version": "1.0.0",
|
||||
"main": "expo-router/entry",
|
||||
"scripts": {
|
||||
"dev": "expo start --dev-client",
|
||||
"start": "expo start --dev-client",
|
||||
"ios": "expo run:ios",
|
||||
"android": "expo run:android",
|
||||
"build:dev": "eas build --profile development",
|
||||
"build:preview": "eas build --profile preview",
|
||||
"build:prod": "eas build --profile production",
|
||||
"prebuild": "expo prebuild",
|
||||
"lint": "eslint \"**/*.{js,jsx,ts,tsx}\" && prettier -c \"**/*.{js,jsx,ts,tsx,json}\"",
|
||||
"format": "eslint \"**/*.{js,jsx,ts,tsx}\" --fix && prettier \"**/*.{js,jsx,ts,tsx,json}\" --write",
|
||||
"web": "expo start --web",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/vector-icons": "^14.0.0",
|
||||
"@google/generative-ai": "^0.24.1",
|
||||
"@react-native-async-storage/async-storage": "2.1.2",
|
||||
"@react-native-clipboard/clipboard": "^1.16.2",
|
||||
"@react-navigation/native": "^7.0.3",
|
||||
"expo": "^53.0.11",
|
||||
"expo-application": "~6.1.4",
|
||||
"expo-device": "~7.1.4",
|
||||
"expo-secure-store": "~14.2.3",
|
||||
"expo-blur": "^14.1.5",
|
||||
"expo-camera": "^16.1.8",
|
||||
"expo-constants": "~17.1.4",
|
||||
"expo-dev-client": "~5.2.0",
|
||||
"expo-dev-launcher": "^5.0.17",
|
||||
"expo-file-system": "^18.1.10",
|
||||
"expo-image-picker": "^16.1.4",
|
||||
"expo-linking": "~7.1.4",
|
||||
"expo-location": "^18.1.5",
|
||||
"expo-router": "~5.1.0",
|
||||
"expo-sqlite": "^15.2.12",
|
||||
"expo-status-bar": "~2.2.3",
|
||||
"expo-symbols": "~0.4.5",
|
||||
"expo-system-ui": "~5.0.6",
|
||||
"expo-web-browser": "~14.1.6",
|
||||
"nativewind": "latest",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0",
|
||||
"react-native": "0.79.3",
|
||||
"react-native-context-menu-view": "^1.19.0",
|
||||
"react-native-gesture-handler": "~2.24.0",
|
||||
"react-native-reanimated": "~3.17.4",
|
||||
"react-native-safe-area-context": "5.4.0",
|
||||
"react-native-screens": "~4.11.1",
|
||||
"react-native-uuid": "^2.0.3",
|
||||
"react-native-web": "^0.20.0",
|
||||
"zustand": "^4.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.20.0",
|
||||
"@types/react": "~19.0.10",
|
||||
"ajv": "^8.12.0",
|
||||
"eslint": "^9.25.1",
|
||||
"eslint-config-expo": "^9.2.0",
|
||||
"eslint-config-prettier": "^10.1.2",
|
||||
"prettier": "^3.2.5",
|
||||
"prettier-plugin-tailwindcss": "^0.5.11",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "~5.8.3"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
module.exports = {
|
||||
printWidth: 100,
|
||||
tabWidth: 2,
|
||||
singleQuote: true,
|
||||
bracketSameLine: true,
|
||||
trailingComma: 'es5',
|
||||
printWidth: 100,
|
||||
tabWidth: 2,
|
||||
singleQuote: true,
|
||||
bracketSameLine: true,
|
||||
trailingComma: 'es5',
|
||||
|
||||
plugins: [require.resolve('prettier-plugin-tailwindcss')],
|
||||
tailwindAttributes: ['className'],
|
||||
plugins: [require.resolve('prettier-plugin-tailwindcss')],
|
||||
tailwindAttributes: ['className'],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -8,143 +8,143 @@ import { useAuthStore } from '../store/AuthStore';
|
|||
import { tokenManager } from './auth/tokenManager';
|
||||
|
||||
export class DataClearingService {
|
||||
private static instance: DataClearingService;
|
||||
private static instance: DataClearingService;
|
||||
|
||||
public static getInstance(): DataClearingService {
|
||||
if (!DataClearingService.instance) {
|
||||
DataClearingService.instance = new DataClearingService();
|
||||
}
|
||||
return DataClearingService.instance;
|
||||
}
|
||||
public static getInstance(): DataClearingService {
|
||||
if (!DataClearingService.instance) {
|
||||
DataClearingService.instance = new DataClearingService();
|
||||
}
|
||||
return DataClearingService.instance;
|
||||
}
|
||||
|
||||
async clearAllData(): Promise<{ success: boolean; errors: string[] }> {
|
||||
const errors: string[] = [];
|
||||
async clearAllData(): Promise<{ success: boolean; errors: string[] }> {
|
||||
const errors: string[] = [];
|
||||
|
||||
try {
|
||||
// 1. Clear SQLite database
|
||||
await this.clearDatabase();
|
||||
} catch (error) {
|
||||
errors.push(`Database clearing failed: ${error}`);
|
||||
}
|
||||
try {
|
||||
// 1. Clear SQLite database
|
||||
await this.clearDatabase();
|
||||
} catch (error) {
|
||||
errors.push(`Database clearing failed: ${error}`);
|
||||
}
|
||||
|
||||
try {
|
||||
// 2. Clear photo storage
|
||||
await this.clearPhotoStorage();
|
||||
} catch (error) {
|
||||
errors.push(`Photo storage clearing failed: ${error}`);
|
||||
}
|
||||
try {
|
||||
// 2. Clear photo storage
|
||||
await this.clearPhotoStorage();
|
||||
} catch (error) {
|
||||
errors.push(`Photo storage clearing failed: ${error}`);
|
||||
}
|
||||
|
||||
try {
|
||||
// 3. Reset Zustand stores
|
||||
this.resetZustandStores();
|
||||
} catch (error) {
|
||||
errors.push(`State reset failed: ${error}`);
|
||||
}
|
||||
try {
|
||||
// 3. Reset Zustand stores
|
||||
this.resetZustandStores();
|
||||
} catch (error) {
|
||||
errors.push(`State reset failed: ${error}`);
|
||||
}
|
||||
|
||||
try {
|
||||
// 4. Clear AsyncStorage
|
||||
await this.clearAsyncStorage();
|
||||
} catch (error) {
|
||||
errors.push(`AsyncStorage clearing failed: ${error}`);
|
||||
}
|
||||
try {
|
||||
// 4. Clear AsyncStorage
|
||||
await this.clearAsyncStorage();
|
||||
} catch (error) {
|
||||
errors.push(`AsyncStorage clearing failed: ${error}`);
|
||||
}
|
||||
|
||||
try {
|
||||
// 5. Sign out and clear auth tokens
|
||||
await this.signOutAndClearAuth();
|
||||
} catch (error) {
|
||||
errors.push(`Auth clearing failed: ${error}`);
|
||||
}
|
||||
try {
|
||||
// 5. Sign out and clear auth tokens
|
||||
await this.signOutAndClearAuth();
|
||||
} catch (error) {
|
||||
errors.push(`Auth clearing failed: ${error}`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: errors.length === 0,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: errors.length === 0,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
private async signOutAndClearAuth(): Promise<void> {
|
||||
// Sign out from auth store
|
||||
await useAuthStore.getState().signOut();
|
||||
// Clear all tokens
|
||||
await tokenManager.clearTokens();
|
||||
}
|
||||
private async signOutAndClearAuth(): Promise<void> {
|
||||
// Sign out from auth store
|
||||
await useAuthStore.getState().signOut();
|
||||
// Clear all tokens
|
||||
await tokenManager.clearTokens();
|
||||
}
|
||||
|
||||
private async clearDatabase(): Promise<void> {
|
||||
const db = SQLiteService.getInstance();
|
||||
private async clearDatabase(): Promise<void> {
|
||||
const db = SQLiteService.getInstance();
|
||||
|
||||
// Clear all main tables while preserving structure
|
||||
await db.executeRaw('DELETE FROM meals');
|
||||
await db.executeRaw('DELETE FROM food_items');
|
||||
await db.executeRaw('DELETE FROM sync_metadata');
|
||||
// Clear all main tables while preserving structure
|
||||
await db.executeRaw('DELETE FROM meals');
|
||||
await db.executeRaw('DELETE FROM food_items');
|
||||
await db.executeRaw('DELETE FROM sync_metadata');
|
||||
|
||||
// Reset user preferences to defaults but keep the table
|
||||
await db.executeRaw('DELETE FROM user_preferences');
|
||||
// Reset user preferences to defaults but keep the table
|
||||
await db.executeRaw('DELETE FROM user_preferences');
|
||||
|
||||
// Don't delete schema_migrations to preserve database version
|
||||
}
|
||||
// Don't delete schema_migrations to preserve database version
|
||||
}
|
||||
|
||||
private async clearPhotoStorage(): Promise<void> {
|
||||
const photosDir = `${FileSystem.documentDirectory}photos/`;
|
||||
private async clearPhotoStorage(): Promise<void> {
|
||||
const photosDir = `${FileSystem.documentDirectory}photos/`;
|
||||
|
||||
// Check if photos directory exists
|
||||
const dirInfo = await FileSystem.getInfoAsync(photosDir);
|
||||
if (!dirInfo.exists) return;
|
||||
// Check if photos directory exists
|
||||
const dirInfo = await FileSystem.getInfoAsync(photosDir);
|
||||
if (!dirInfo.exists) return;
|
||||
|
||||
// Get all files in photos directory
|
||||
const files = await FileSystem.readDirectoryAsync(photosDir);
|
||||
// Get all files in photos directory
|
||||
const files = await FileSystem.readDirectoryAsync(photosDir);
|
||||
|
||||
// Delete all photo files
|
||||
for (const file of files) {
|
||||
const filePath = `${photosDir}${file}`;
|
||||
await FileSystem.deleteAsync(filePath, { idempotent: true });
|
||||
}
|
||||
// Delete all photo files
|
||||
for (const file of files) {
|
||||
const filePath = `${photosDir}${file}`;
|
||||
await FileSystem.deleteAsync(filePath, { idempotent: true });
|
||||
}
|
||||
|
||||
// Also cleanup any temp photos
|
||||
await PhotoService.getInstance().cleanupTempPhotos();
|
||||
}
|
||||
// Also cleanup any temp photos
|
||||
await PhotoService.getInstance().cleanupTempPhotos();
|
||||
}
|
||||
|
||||
private resetZustandStores(): void {
|
||||
// Reset MealStore
|
||||
const mealStore = useMealStore.getState();
|
||||
mealStore.clearAllMeals();
|
||||
mealStore.setSelectedMeal(null);
|
||||
private resetZustandStores(): void {
|
||||
// Reset MealStore
|
||||
const mealStore = useMealStore.getState();
|
||||
mealStore.clearAllMeals();
|
||||
mealStore.setSelectedMeal(null);
|
||||
|
||||
// Reset AppStore (but preserve theme preference as it will be handled by AsyncStorage)
|
||||
const appStore = useAppStore.getState();
|
||||
appStore.resetStats();
|
||||
// Reset AppStore (but preserve theme preference as it will be handled by AsyncStorage)
|
||||
const appStore = useAppStore.getState();
|
||||
appStore.resetStats();
|
||||
|
||||
// Reset other app store states except theme
|
||||
const currentTheme = appStore.theme;
|
||||
appStore.resetToDefaults();
|
||||
appStore.setTheme(currentTheme); // Preserve current theme
|
||||
}
|
||||
// Reset other app store states except theme
|
||||
const currentTheme = appStore.theme;
|
||||
appStore.resetToDefaults();
|
||||
appStore.setTheme(currentTheme); // Preserve current theme
|
||||
}
|
||||
|
||||
private async clearAsyncStorage(): Promise<void> {
|
||||
// Get all keys
|
||||
const keys = await AsyncStorage.getAllKeys();
|
||||
private async clearAsyncStorage(): Promise<void> {
|
||||
// Get all keys
|
||||
const keys = await AsyncStorage.getAllKeys();
|
||||
|
||||
// Define keys to clear (all except we might want to preserve some)
|
||||
const keysToRemove = keys.filter(
|
||||
(key) => key !== 'user-theme-preference' // We might want to preserve theme preference
|
||||
);
|
||||
// Define keys to clear (all except we might want to preserve some)
|
||||
const keysToRemove = keys.filter(
|
||||
(key) => key !== 'user-theme-preference' // We might want to preserve theme preference
|
||||
);
|
||||
|
||||
// Clear selected keys
|
||||
if (keysToRemove.length > 0) {
|
||||
await AsyncStorage.multiRemove(keysToRemove);
|
||||
}
|
||||
}
|
||||
// Clear selected keys
|
||||
if (keysToRemove.length > 0) {
|
||||
await AsyncStorage.multiRemove(keysToRemove);
|
||||
}
|
||||
}
|
||||
|
||||
// Optional: Clear everything including theme preference
|
||||
async clearAllDataIncludingTheme(): Promise<{ success: boolean; errors: string[] }> {
|
||||
const result = await this.clearAllData();
|
||||
// Optional: Clear everything including theme preference
|
||||
async clearAllDataIncludingTheme(): Promise<{ success: boolean; errors: string[] }> {
|
||||
const result = await this.clearAllData();
|
||||
|
||||
try {
|
||||
// Also clear theme preference
|
||||
await AsyncStorage.removeItem('user-theme-preference');
|
||||
} catch (error) {
|
||||
result.errors.push(`Theme preference clearing failed: ${error}`);
|
||||
result.success = false;
|
||||
}
|
||||
try {
|
||||
// Also clear theme preference
|
||||
await AsyncStorage.removeItem('user-theme-preference');
|
||||
} catch (error) {
|
||||
result.errors.push(`Theme preference clearing failed: ${error}`);
|
||||
result.success = false;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,217 +1,217 @@
|
|||
import * as Location from 'expo-location';
|
||||
|
||||
export interface LocationData {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
accuracy: number | null;
|
||||
altitude: number | null;
|
||||
altitudeAccuracy: number | null;
|
||||
heading: number | null;
|
||||
speed: number | null;
|
||||
timestamp: number;
|
||||
address?: LocationAddress;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
accuracy: number | null;
|
||||
altitude: number | null;
|
||||
altitudeAccuracy: number | null;
|
||||
heading: number | null;
|
||||
speed: number | null;
|
||||
timestamp: number;
|
||||
address?: LocationAddress;
|
||||
}
|
||||
|
||||
export interface LocationAddress {
|
||||
name?: string;
|
||||
street?: string;
|
||||
city?: string;
|
||||
region?: string;
|
||||
country?: string;
|
||||
postalCode?: string;
|
||||
formattedAddress?: string;
|
||||
name?: string;
|
||||
street?: string;
|
||||
city?: string;
|
||||
region?: string;
|
||||
country?: string;
|
||||
postalCode?: string;
|
||||
formattedAddress?: string;
|
||||
}
|
||||
|
||||
export class LocationService {
|
||||
private static instance: LocationService;
|
||||
private hasPermission: boolean = false;
|
||||
private static instance: LocationService;
|
||||
private hasPermission: boolean = false;
|
||||
|
||||
private constructor() {}
|
||||
private constructor() {}
|
||||
|
||||
public static getInstance(): LocationService {
|
||||
if (!LocationService.instance) {
|
||||
LocationService.instance = new LocationService();
|
||||
}
|
||||
return LocationService.instance;
|
||||
}
|
||||
public static getInstance(): LocationService {
|
||||
if (!LocationService.instance) {
|
||||
LocationService.instance = new LocationService();
|
||||
}
|
||||
return LocationService.instance;
|
||||
}
|
||||
|
||||
public async checkPermissions(): Promise<boolean> {
|
||||
try {
|
||||
const { status } = await Location.getForegroundPermissionsAsync();
|
||||
this.hasPermission = status === 'granted';
|
||||
return this.hasPermission;
|
||||
} catch (error) {
|
||||
console.error('Failed to check location permissions:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
public async checkPermissions(): Promise<boolean> {
|
||||
try {
|
||||
const { status } = await Location.getForegroundPermissionsAsync();
|
||||
this.hasPermission = status === 'granted';
|
||||
return this.hasPermission;
|
||||
} catch (error) {
|
||||
console.error('Failed to check location permissions:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async requestPermissions(): Promise<boolean> {
|
||||
try {
|
||||
const { status } = await Location.requestForegroundPermissionsAsync();
|
||||
this.hasPermission = status === 'granted';
|
||||
return this.hasPermission;
|
||||
} catch (error) {
|
||||
console.error('Failed to request location permissions:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
public async requestPermissions(): Promise<boolean> {
|
||||
try {
|
||||
const { status } = await Location.requestForegroundPermissionsAsync();
|
||||
this.hasPermission = status === 'granted';
|
||||
return this.hasPermission;
|
||||
} catch (error) {
|
||||
console.error('Failed to request location permissions:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async getCurrentLocation(): Promise<LocationData | null> {
|
||||
try {
|
||||
// Check permissions first
|
||||
if (!this.hasPermission) {
|
||||
const granted = await this.requestPermissions();
|
||||
if (!granted) {
|
||||
console.log('Location permission denied');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
public async getCurrentLocation(): Promise<LocationData | null> {
|
||||
try {
|
||||
// Check permissions first
|
||||
if (!this.hasPermission) {
|
||||
const granted = await this.requestPermissions();
|
||||
if (!granted) {
|
||||
console.log('Location permission denied');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Get current location with high accuracy
|
||||
const location = await Location.getCurrentPositionAsync({
|
||||
accuracy: Location.Accuracy.High,
|
||||
timeInterval: 5000, // 5 seconds
|
||||
mayShowUserSettingsDialog: true,
|
||||
});
|
||||
// Get current location with high accuracy
|
||||
const location = await Location.getCurrentPositionAsync({
|
||||
accuracy: Location.Accuracy.High,
|
||||
timeInterval: 5000, // 5 seconds
|
||||
mayShowUserSettingsDialog: true,
|
||||
});
|
||||
|
||||
const locationData: LocationData = {
|
||||
latitude: location.coords.latitude,
|
||||
longitude: location.coords.longitude,
|
||||
accuracy: location.coords.accuracy,
|
||||
altitude: location.coords.altitude,
|
||||
altitudeAccuracy: location.coords.altitudeAccuracy,
|
||||
heading: location.coords.heading,
|
||||
speed: location.coords.speed,
|
||||
timestamp: location.timestamp,
|
||||
};
|
||||
const locationData: LocationData = {
|
||||
latitude: location.coords.latitude,
|
||||
longitude: location.coords.longitude,
|
||||
accuracy: location.coords.accuracy,
|
||||
altitude: location.coords.altitude,
|
||||
altitudeAccuracy: location.coords.altitudeAccuracy,
|
||||
heading: location.coords.heading,
|
||||
speed: location.coords.speed,
|
||||
timestamp: location.timestamp,
|
||||
};
|
||||
|
||||
// Try to get address
|
||||
try {
|
||||
const address = await this.reverseGeocode(locationData.latitude, locationData.longitude);
|
||||
locationData.address = address;
|
||||
} catch (error) {
|
||||
console.warn('Reverse geocoding failed:', error);
|
||||
}
|
||||
// Try to get address
|
||||
try {
|
||||
const address = await this.reverseGeocode(locationData.latitude, locationData.longitude);
|
||||
locationData.address = address;
|
||||
} catch (error) {
|
||||
console.warn('Reverse geocoding failed:', error);
|
||||
}
|
||||
|
||||
return locationData;
|
||||
} catch (error) {
|
||||
console.error('Failed to get current location:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return locationData;
|
||||
} catch (error) {
|
||||
console.error('Failed to get current location:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async reverseGeocode(
|
||||
latitude: number,
|
||||
longitude: number
|
||||
): Promise<LocationAddress | null> {
|
||||
try {
|
||||
const results = await Location.reverseGeocodeAsync({
|
||||
latitude,
|
||||
longitude,
|
||||
});
|
||||
public async reverseGeocode(
|
||||
latitude: number,
|
||||
longitude: number
|
||||
): Promise<LocationAddress | null> {
|
||||
try {
|
||||
const results = await Location.reverseGeocodeAsync({
|
||||
latitude,
|
||||
longitude,
|
||||
});
|
||||
|
||||
if (results && results.length > 0) {
|
||||
const result = results[0];
|
||||
if (results && results.length > 0) {
|
||||
const result = results[0];
|
||||
|
||||
// Build formatted address
|
||||
const addressParts = [];
|
||||
// Build formatted address
|
||||
const addressParts = [];
|
||||
|
||||
// Try to detect common places
|
||||
let placeName = result.name;
|
||||
if (!placeName && result.street) {
|
||||
placeName = result.street;
|
||||
}
|
||||
// Try to detect common places
|
||||
let placeName = result.name;
|
||||
if (!placeName && result.street) {
|
||||
placeName = result.street;
|
||||
}
|
||||
|
||||
// Build formatted address
|
||||
if (result.streetNumber) addressParts.push(result.streetNumber);
|
||||
if (result.street) addressParts.push(result.street);
|
||||
const streetAddress = addressParts.join(' ');
|
||||
// Build formatted address
|
||||
if (result.streetNumber) addressParts.push(result.streetNumber);
|
||||
if (result.street) addressParts.push(result.street);
|
||||
const streetAddress = addressParts.join(' ');
|
||||
|
||||
const cityParts = [];
|
||||
if (result.city) cityParts.push(result.city);
|
||||
if (result.region) cityParts.push(result.region);
|
||||
if (result.postalCode) cityParts.push(result.postalCode);
|
||||
const cityAddress = cityParts.join(', ');
|
||||
const cityParts = [];
|
||||
if (result.city) cityParts.push(result.city);
|
||||
if (result.region) cityParts.push(result.region);
|
||||
if (result.postalCode) cityParts.push(result.postalCode);
|
||||
const cityAddress = cityParts.join(', ');
|
||||
|
||||
const formattedAddress = [streetAddress, cityAddress, result.country]
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
const formattedAddress = [streetAddress, cityAddress, result.country]
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
|
||||
return {
|
||||
name: placeName || undefined,
|
||||
street: streetAddress || undefined,
|
||||
city: result.city || undefined,
|
||||
region: result.region || undefined,
|
||||
country: result.country || undefined,
|
||||
postalCode: result.postalCode || undefined,
|
||||
formattedAddress: formattedAddress || undefined,
|
||||
};
|
||||
}
|
||||
return {
|
||||
name: placeName || undefined,
|
||||
street: streetAddress || undefined,
|
||||
city: result.city || undefined,
|
||||
region: result.region || undefined,
|
||||
country: result.country || undefined,
|
||||
postalCode: result.postalCode || undefined,
|
||||
formattedAddress: formattedAddress || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Reverse geocoding failed:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Reverse geocoding failed:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public getReadableLocationName(address: LocationAddress | null): string {
|
||||
if (!address) return 'Unbekannter Ort';
|
||||
public getReadableLocationName(address: LocationAddress | null): string {
|
||||
if (!address) return 'Unbekannter Ort';
|
||||
|
||||
// Priority: name > street > city > region > country
|
||||
if (address.name) return address.name;
|
||||
if (address.street) return address.street;
|
||||
if (address.city) return address.city;
|
||||
if (address.region) return address.region;
|
||||
if (address.country) return address.country;
|
||||
// Priority: name > street > city > region > country
|
||||
if (address.name) return address.name;
|
||||
if (address.street) return address.street;
|
||||
if (address.city) return address.city;
|
||||
if (address.region) return address.region;
|
||||
if (address.country) return address.country;
|
||||
|
||||
return 'Unbekannter Ort';
|
||||
}
|
||||
return 'Unbekannter Ort';
|
||||
}
|
||||
|
||||
public formatLocationForDisplay(address: LocationAddress | null): string {
|
||||
if (!address) return '';
|
||||
public formatLocationForDisplay(address: LocationAddress | null): string {
|
||||
if (!address) return '';
|
||||
|
||||
// For display in UI, show a concise version
|
||||
if (address.name && address.city) {
|
||||
return `${address.name}, ${address.city}`;
|
||||
}
|
||||
// For display in UI, show a concise version
|
||||
if (address.name && address.city) {
|
||||
return `${address.name}, ${address.city}`;
|
||||
}
|
||||
|
||||
if (address.street && address.city) {
|
||||
return `${address.street}, ${address.city}`;
|
||||
}
|
||||
if (address.street && address.city) {
|
||||
return `${address.street}, ${address.city}`;
|
||||
}
|
||||
|
||||
if (address.city) {
|
||||
return address.city;
|
||||
}
|
||||
if (address.city) {
|
||||
return address.city;
|
||||
}
|
||||
|
||||
return address.formattedAddress || 'Unbekannter Ort';
|
||||
}
|
||||
return address.formattedAddress || 'Unbekannter Ort';
|
||||
}
|
||||
|
||||
public calculateDistance(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||
// Haversine formula to calculate distance in meters
|
||||
const R = 6371e3; // Earth's radius in meters
|
||||
const φ1 = (lat1 * Math.PI) / 180;
|
||||
const φ2 = (lat2 * Math.PI) / 180;
|
||||
const Δφ = ((lat2 - lat1) * Math.PI) / 180;
|
||||
const Δλ = ((lon2 - lon1) * Math.PI) / 180;
|
||||
public calculateDistance(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||
// Haversine formula to calculate distance in meters
|
||||
const R = 6371e3; // Earth's radius in meters
|
||||
const φ1 = (lat1 * Math.PI) / 180;
|
||||
const φ2 = (lat2 * Math.PI) / 180;
|
||||
const Δφ = ((lat2 - lat1) * Math.PI) / 180;
|
||||
const Δλ = ((lon2 - lon1) * Math.PI) / 180;
|
||||
|
||||
const a =
|
||||
Math.sin(Δφ / 2) * Math.sin(Δφ / 2) +
|
||||
Math.cos(φ1) * Math.cos(φ2) * Math.sin(Δλ / 2) * Math.sin(Δλ / 2);
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
const a =
|
||||
Math.sin(Δφ / 2) * Math.sin(Δφ / 2) +
|
||||
Math.cos(φ1) * Math.cos(φ2) * Math.sin(Δλ / 2) * Math.sin(Δλ / 2);
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
|
||||
return R * c; // Distance in meters
|
||||
}
|
||||
return R * c; // Distance in meters
|
||||
}
|
||||
|
||||
public isNearLocation(
|
||||
currentLat: number,
|
||||
currentLon: number,
|
||||
targetLat: number,
|
||||
targetLon: number,
|
||||
thresholdMeters: number = 100
|
||||
): boolean {
|
||||
const distance = this.calculateDistance(currentLat, currentLon, targetLat, targetLon);
|
||||
return distance <= thresholdMeters;
|
||||
}
|
||||
public isNearLocation(
|
||||
currentLat: number,
|
||||
currentLon: number,
|
||||
targetLat: number,
|
||||
targetLon: number,
|
||||
thresholdMeters: number = 100
|
||||
): boolean {
|
||||
const distance = this.calculateDistance(currentLat, currentLon, targetLat, targetLon);
|
||||
return distance <= thresholdMeters;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,207 +1,207 @@
|
|||
import { SQLiteService } from './database/SQLiteService';
|
||||
|
||||
export interface UserPreferences {
|
||||
locationEnabled: boolean;
|
||||
locationPermissionAsked: boolean;
|
||||
defaultMealType: 'breakfast' | 'lunch' | 'dinner' | 'snack';
|
||||
healthGoalCalories?: number;
|
||||
healthGoalProtein?: number;
|
||||
healthGoalCarbs?: number;
|
||||
healthGoalFat?: number;
|
||||
notificationsEnabled: boolean;
|
||||
reminderTimes: string[]; // Array of times like ["08:00", "12:30", "19:00"]
|
||||
theme: 'light' | 'dark' | 'system';
|
||||
language: 'de' | 'en';
|
||||
locationEnabled: boolean;
|
||||
locationPermissionAsked: boolean;
|
||||
defaultMealType: 'breakfast' | 'lunch' | 'dinner' | 'snack';
|
||||
healthGoalCalories?: number;
|
||||
healthGoalProtein?: number;
|
||||
healthGoalCarbs?: number;
|
||||
healthGoalFat?: number;
|
||||
notificationsEnabled: boolean;
|
||||
reminderTimes: string[]; // Array of times like ["08:00", "12:30", "19:00"]
|
||||
theme: 'light' | 'dark' | 'system';
|
||||
language: 'de' | 'en';
|
||||
}
|
||||
|
||||
const DEFAULT_PREFERENCES: UserPreferences = {
|
||||
locationEnabled: true,
|
||||
locationPermissionAsked: false,
|
||||
defaultMealType: 'lunch',
|
||||
notificationsEnabled: true,
|
||||
reminderTimes: [],
|
||||
theme: 'system',
|
||||
language: 'de',
|
||||
locationEnabled: true,
|
||||
locationPermissionAsked: false,
|
||||
defaultMealType: 'lunch',
|
||||
notificationsEnabled: true,
|
||||
reminderTimes: [],
|
||||
theme: 'system',
|
||||
language: 'de',
|
||||
};
|
||||
|
||||
export class UserPreferencesService {
|
||||
private static instance: UserPreferencesService;
|
||||
private dbService: SQLiteService;
|
||||
private cachedPreferences: UserPreferences | null = null;
|
||||
private static instance: UserPreferencesService;
|
||||
private dbService: SQLiteService;
|
||||
private cachedPreferences: UserPreferences | null = null;
|
||||
|
||||
private constructor() {
|
||||
this.dbService = SQLiteService.getInstance();
|
||||
}
|
||||
private constructor() {
|
||||
this.dbService = SQLiteService.getInstance();
|
||||
}
|
||||
|
||||
public static getInstance(): UserPreferencesService {
|
||||
if (!UserPreferencesService.instance) {
|
||||
UserPreferencesService.instance = new UserPreferencesService();
|
||||
}
|
||||
return UserPreferencesService.instance;
|
||||
}
|
||||
public static getInstance(): UserPreferencesService {
|
||||
if (!UserPreferencesService.instance) {
|
||||
UserPreferencesService.instance = new UserPreferencesService();
|
||||
}
|
||||
return UserPreferencesService.instance;
|
||||
}
|
||||
|
||||
public async initialize(): Promise<void> {
|
||||
// Load preferences into cache
|
||||
await this.loadPreferences();
|
||||
}
|
||||
public async initialize(): Promise<void> {
|
||||
// Load preferences into cache
|
||||
await this.loadPreferences();
|
||||
}
|
||||
|
||||
private async loadPreferences(): Promise<UserPreferences> {
|
||||
try {
|
||||
const db = await this.dbService.getDatabase();
|
||||
|
||||
// Check if table exists first
|
||||
const tableExists = await db.getFirstAsync<{ name: string }>(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='user_preferences'"
|
||||
);
|
||||
|
||||
if (!tableExists) {
|
||||
console.log('User preferences table does not exist yet, using defaults');
|
||||
this.cachedPreferences = { ...DEFAULT_PREFERENCES };
|
||||
return this.cachedPreferences;
|
||||
}
|
||||
private async loadPreferences(): Promise<UserPreferences> {
|
||||
try {
|
||||
const db = await this.dbService.getDatabase();
|
||||
|
||||
const rows = await db.getAllAsync<{ key: string; value: string; type: string }>(
|
||||
'SELECT key, value, type FROM user_preferences'
|
||||
);
|
||||
// Check if table exists first
|
||||
const tableExists = await db.getFirstAsync<{ name: string }>(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='user_preferences'"
|
||||
);
|
||||
|
||||
const preferences = { ...DEFAULT_PREFERENCES };
|
||||
if (!tableExists) {
|
||||
console.log('User preferences table does not exist yet, using defaults');
|
||||
this.cachedPreferences = { ...DEFAULT_PREFERENCES };
|
||||
return this.cachedPreferences;
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
const value = this.parseValue(row.value, row.type);
|
||||
(preferences as any)[row.key] = value;
|
||||
}
|
||||
const rows = await db.getAllAsync<{ key: string; value: string; type: string }>(
|
||||
'SELECT key, value, type FROM user_preferences'
|
||||
);
|
||||
|
||||
this.cachedPreferences = preferences;
|
||||
return preferences;
|
||||
} catch (error) {
|
||||
console.error('Failed to load preferences:', error);
|
||||
this.cachedPreferences = { ...DEFAULT_PREFERENCES };
|
||||
return this.cachedPreferences;
|
||||
}
|
||||
}
|
||||
const preferences = { ...DEFAULT_PREFERENCES };
|
||||
|
||||
private parseValue(value: string, type: string): any {
|
||||
switch (type) {
|
||||
case 'boolean':
|
||||
return value === 'true';
|
||||
case 'number':
|
||||
return parseFloat(value);
|
||||
case 'array':
|
||||
return JSON.parse(value);
|
||||
case 'string':
|
||||
default:
|
||||
return value;
|
||||
}
|
||||
}
|
||||
for (const row of rows) {
|
||||
const value = this.parseValue(row.value, row.type);
|
||||
(preferences as any)[row.key] = value;
|
||||
}
|
||||
|
||||
private getValueType(value: any): string {
|
||||
if (typeof value === 'boolean') return 'boolean';
|
||||
if (typeof value === 'number') return 'number';
|
||||
if (Array.isArray(value)) return 'array';
|
||||
return 'string';
|
||||
}
|
||||
this.cachedPreferences = preferences;
|
||||
return preferences;
|
||||
} catch (error) {
|
||||
console.error('Failed to load preferences:', error);
|
||||
this.cachedPreferences = { ...DEFAULT_PREFERENCES };
|
||||
return this.cachedPreferences;
|
||||
}
|
||||
}
|
||||
|
||||
public async getPreferences(): Promise<UserPreferences> {
|
||||
if (!this.cachedPreferences) {
|
||||
await this.loadPreferences();
|
||||
}
|
||||
return this.cachedPreferences!;
|
||||
}
|
||||
private parseValue(value: string, type: string): any {
|
||||
switch (type) {
|
||||
case 'boolean':
|
||||
return value === 'true';
|
||||
case 'number':
|
||||
return parseFloat(value);
|
||||
case 'array':
|
||||
return JSON.parse(value);
|
||||
case 'string':
|
||||
default:
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
public async updatePreference<K extends keyof UserPreferences>(
|
||||
key: K,
|
||||
value: UserPreferences[K]
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Update cache immediately for responsive UI
|
||||
if (!this.cachedPreferences) {
|
||||
this.cachedPreferences = { ...DEFAULT_PREFERENCES };
|
||||
}
|
||||
this.cachedPreferences[key] = value;
|
||||
private getValueType(value: any): string {
|
||||
if (typeof value === 'boolean') return 'boolean';
|
||||
if (typeof value === 'number') return 'number';
|
||||
if (Array.isArray(value)) return 'array';
|
||||
return 'string';
|
||||
}
|
||||
|
||||
const db = await this.dbService.getDatabase();
|
||||
|
||||
// Check if table exists
|
||||
const tableExists = await db.getFirstAsync<{ name: string }>(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='user_preferences'"
|
||||
);
|
||||
|
||||
if (!tableExists) {
|
||||
console.log('User preferences table does not exist yet, cache updated only');
|
||||
return;
|
||||
}
|
||||
public async getPreferences(): Promise<UserPreferences> {
|
||||
if (!this.cachedPreferences) {
|
||||
await this.loadPreferences();
|
||||
}
|
||||
return this.cachedPreferences!;
|
||||
}
|
||||
|
||||
const type = this.getValueType(value);
|
||||
const serializedValue = type === 'array' ? JSON.stringify(value) : String(value);
|
||||
public async updatePreference<K extends keyof UserPreferences>(
|
||||
key: K,
|
||||
value: UserPreferences[K]
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Update cache immediately for responsive UI
|
||||
if (!this.cachedPreferences) {
|
||||
this.cachedPreferences = { ...DEFAULT_PREFERENCES };
|
||||
}
|
||||
this.cachedPreferences[key] = value;
|
||||
|
||||
await db.runAsync(
|
||||
`INSERT OR REPLACE INTO user_preferences (key, value, type, updated_at)
|
||||
const db = await this.dbService.getDatabase();
|
||||
|
||||
// Check if table exists
|
||||
const tableExists = await db.getFirstAsync<{ name: string }>(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='user_preferences'"
|
||||
);
|
||||
|
||||
if (!tableExists) {
|
||||
console.log('User preferences table does not exist yet, cache updated only');
|
||||
return;
|
||||
}
|
||||
|
||||
const type = this.getValueType(value);
|
||||
const serializedValue = type === 'array' ? JSON.stringify(value) : String(value);
|
||||
|
||||
await db.runAsync(
|
||||
`INSERT OR REPLACE INTO user_preferences (key, value, type, updated_at)
|
||||
VALUES (?, ?, ?, datetime('now'))`,
|
||||
[key, serializedValue, type]
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`Failed to update preference ${key}:`, error);
|
||||
// Don't throw - we already updated the cache
|
||||
}
|
||||
}
|
||||
[key, serializedValue, type]
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`Failed to update preference ${key}:`, error);
|
||||
// Don't throw - we already updated the cache
|
||||
}
|
||||
}
|
||||
|
||||
public async updateMultiplePreferences(updates: Partial<UserPreferences>): Promise<void> {
|
||||
const db = await this.dbService.getDatabase();
|
||||
public async updateMultiplePreferences(updates: Partial<UserPreferences>): Promise<void> {
|
||||
const db = await this.dbService.getDatabase();
|
||||
|
||||
try {
|
||||
await db.execAsync('BEGIN TRANSACTION');
|
||||
try {
|
||||
await db.execAsync('BEGIN TRANSACTION');
|
||||
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
const type = this.getValueType(value);
|
||||
const serializedValue = type === 'array' ? JSON.stringify(value) : String(value);
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
const type = this.getValueType(value);
|
||||
const serializedValue = type === 'array' ? JSON.stringify(value) : String(value);
|
||||
|
||||
await db.runAsync(
|
||||
`INSERT OR REPLACE INTO user_preferences (key, value, type, updated_at)
|
||||
await db.runAsync(
|
||||
`INSERT OR REPLACE INTO user_preferences (key, value, type, updated_at)
|
||||
VALUES (?, ?, ?, datetime('now'))`,
|
||||
[key, serializedValue, type]
|
||||
);
|
||||
}
|
||||
[key, serializedValue, type]
|
||||
);
|
||||
}
|
||||
|
||||
await db.execAsync('COMMIT');
|
||||
await db.execAsync('COMMIT');
|
||||
|
||||
// Update cache
|
||||
if (this.cachedPreferences) {
|
||||
Object.assign(this.cachedPreferences, updates);
|
||||
}
|
||||
} catch (error) {
|
||||
await db.execAsync('ROLLBACK');
|
||||
console.error('Failed to update preferences:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
// Update cache
|
||||
if (this.cachedPreferences) {
|
||||
Object.assign(this.cachedPreferences, updates);
|
||||
}
|
||||
} catch (error) {
|
||||
await db.execAsync('ROLLBACK');
|
||||
console.error('Failed to update preferences:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async resetToDefaults(): Promise<void> {
|
||||
try {
|
||||
const db = await this.dbService.getDatabase();
|
||||
await db.execAsync('DELETE FROM user_preferences');
|
||||
this.cachedPreferences = { ...DEFAULT_PREFERENCES };
|
||||
} catch (error) {
|
||||
console.error('Failed to reset preferences:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
public async resetToDefaults(): Promise<void> {
|
||||
try {
|
||||
const db = await this.dbService.getDatabase();
|
||||
await db.execAsync('DELETE FROM user_preferences');
|
||||
this.cachedPreferences = { ...DEFAULT_PREFERENCES };
|
||||
} catch (error) {
|
||||
console.error('Failed to reset preferences:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Convenience methods
|
||||
public async isLocationEnabled(): Promise<boolean> {
|
||||
const prefs = await this.getPreferences();
|
||||
return prefs.locationEnabled;
|
||||
}
|
||||
// Convenience methods
|
||||
public async isLocationEnabled(): Promise<boolean> {
|
||||
const prefs = await this.getPreferences();
|
||||
return prefs.locationEnabled;
|
||||
}
|
||||
|
||||
public async setLocationEnabled(enabled: boolean): Promise<void> {
|
||||
await this.updatePreference('locationEnabled', enabled);
|
||||
}
|
||||
public async setLocationEnabled(enabled: boolean): Promise<void> {
|
||||
await this.updatePreference('locationEnabled', enabled);
|
||||
}
|
||||
|
||||
public async hasAskedLocationPermission(): Promise<boolean> {
|
||||
const prefs = await this.getPreferences();
|
||||
return prefs.locationPermissionAsked;
|
||||
}
|
||||
public async hasAskedLocationPermission(): Promise<boolean> {
|
||||
const prefs = await this.getPreferences();
|
||||
return prefs.locationPermissionAsked;
|
||||
}
|
||||
|
||||
public async markLocationPermissionAsked(): Promise<void> {
|
||||
await this.updatePreference('locationPermissionAsked', true);
|
||||
}
|
||||
public async markLocationPermissionAsked(): Promise<void> {
|
||||
await this.updatePreference('locationPermissionAsked', true);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,117 +5,117 @@ import Constants from 'expo-constants';
|
|||
import { GeminiAnalysisResult, GeminiError, PromptContext } from '../../types/API';
|
||||
|
||||
interface GeminiConfig {
|
||||
apiKey: string;
|
||||
model: string;
|
||||
temperature: number;
|
||||
maxOutputTokens: number;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
temperature: number;
|
||||
maxOutputTokens: number;
|
||||
}
|
||||
|
||||
interface RetryConfig {
|
||||
maxRetries: number;
|
||||
baseDelay: number;
|
||||
maxDelay: number;
|
||||
backoffMultiplier: number;
|
||||
maxRetries: number;
|
||||
baseDelay: number;
|
||||
maxDelay: number;
|
||||
backoffMultiplier: number;
|
||||
}
|
||||
|
||||
export class GeminiService {
|
||||
private static instance: GeminiService;
|
||||
private genAI: GoogleGenerativeAI | null = null;
|
||||
private model: any = null;
|
||||
private static instance: GeminiService;
|
||||
private genAI: GoogleGenerativeAI | null = null;
|
||||
private model: any = null;
|
||||
|
||||
private config: GeminiConfig = {
|
||||
apiKey:
|
||||
Constants.expoConfig?.extra?.EXPO_PUBLIC_GEMINI_API_KEY ||
|
||||
process.env.EXPO_PUBLIC_GEMINI_API_KEY ||
|
||||
'AIzaSyD6yzHykVCB-g7HmGeNfl2t96UqAW8qwrY',
|
||||
model: 'gemini-1.5-pro-latest',
|
||||
temperature: 0.1, // Low temperature for consistent analysis
|
||||
maxOutputTokens: 2048,
|
||||
};
|
||||
private config: GeminiConfig = {
|
||||
apiKey:
|
||||
Constants.expoConfig?.extra?.EXPO_PUBLIC_GEMINI_API_KEY ||
|
||||
process.env.EXPO_PUBLIC_GEMINI_API_KEY ||
|
||||
'AIzaSyD6yzHykVCB-g7HmGeNfl2t96UqAW8qwrY',
|
||||
model: 'gemini-1.5-pro-latest',
|
||||
temperature: 0.1, // Low temperature for consistent analysis
|
||||
maxOutputTokens: 2048,
|
||||
};
|
||||
|
||||
private requestTimeout = 60000; // 60 seconds timeout
|
||||
private requestTimeout = 60000; // 60 seconds timeout
|
||||
|
||||
private retryConfig: RetryConfig = {
|
||||
maxRetries: 3,
|
||||
baseDelay: 1000, // 1 second
|
||||
maxDelay: 10000, // 10 seconds
|
||||
backoffMultiplier: 2,
|
||||
};
|
||||
private retryConfig: RetryConfig = {
|
||||
maxRetries: 3,
|
||||
baseDelay: 1000, // 1 second
|
||||
maxDelay: 10000, // 10 seconds
|
||||
backoffMultiplier: 2,
|
||||
};
|
||||
|
||||
private constructor() {
|
||||
this.initialize();
|
||||
}
|
||||
private constructor() {
|
||||
this.initialize();
|
||||
}
|
||||
|
||||
public static getInstance(): GeminiService {
|
||||
if (!GeminiService.instance) {
|
||||
GeminiService.instance = new GeminiService();
|
||||
}
|
||||
return GeminiService.instance;
|
||||
}
|
||||
public static getInstance(): GeminiService {
|
||||
if (!GeminiService.instance) {
|
||||
GeminiService.instance = new GeminiService();
|
||||
}
|
||||
return GeminiService.instance;
|
||||
}
|
||||
|
||||
private initialize() {
|
||||
console.log('Initializing GeminiService...');
|
||||
console.log('API Key available:', !!this.config.apiKey);
|
||||
console.log('API Key length:', this.config.apiKey.length);
|
||||
private initialize() {
|
||||
console.log('Initializing GeminiService...');
|
||||
console.log('API Key available:', !!this.config.apiKey);
|
||||
console.log('API Key length:', this.config.apiKey.length);
|
||||
|
||||
if (!this.config.apiKey) {
|
||||
console.warn('Gemini API key not found. Set EXPO_PUBLIC_GEMINI_API_KEY in your environment.');
|
||||
return;
|
||||
}
|
||||
if (!this.config.apiKey) {
|
||||
console.warn('Gemini API key not found. Set EXPO_PUBLIC_GEMINI_API_KEY in your environment.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.genAI = new GoogleGenerativeAI(this.config.apiKey);
|
||||
this.model = this.genAI.getGenerativeModel({
|
||||
model: this.config.model,
|
||||
generationConfig: {
|
||||
temperature: this.config.temperature,
|
||||
maxOutputTokens: this.config.maxOutputTokens,
|
||||
},
|
||||
});
|
||||
console.log('GeminiService initialized successfully');
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize GeminiService:', error);
|
||||
}
|
||||
}
|
||||
try {
|
||||
this.genAI = new GoogleGenerativeAI(this.config.apiKey);
|
||||
this.model = this.genAI.getGenerativeModel({
|
||||
model: this.config.model,
|
||||
generationConfig: {
|
||||
temperature: this.config.temperature,
|
||||
maxOutputTokens: this.config.maxOutputTokens,
|
||||
},
|
||||
});
|
||||
console.log('GeminiService initialized successfully');
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize GeminiService:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts local image file to base64 for Gemini API
|
||||
*/
|
||||
private async imageToBase64(imagePath: string): Promise<string> {
|
||||
try {
|
||||
console.log('Converting image to base64:', imagePath);
|
||||
/**
|
||||
* Converts local image file to base64 for Gemini API
|
||||
*/
|
||||
private async imageToBase64(imagePath: string): Promise<string> {
|
||||
try {
|
||||
console.log('Converting image to base64:', imagePath);
|
||||
|
||||
// Check if file exists
|
||||
const fileInfo = await FileSystem.getInfoAsync(imagePath);
|
||||
if (!fileInfo.exists) {
|
||||
throw new Error(`Image file not found: ${imagePath}`);
|
||||
}
|
||||
// Check if file exists
|
||||
const fileInfo = await FileSystem.getInfoAsync(imagePath);
|
||||
if (!fileInfo.exists) {
|
||||
throw new Error(`Image file not found: ${imagePath}`);
|
||||
}
|
||||
|
||||
// Check file size (limit to 20MB to prevent timeouts)
|
||||
const maxSize = 20 * 1024 * 1024; // 20MB
|
||||
if (fileInfo.size && fileInfo.size > maxSize) {
|
||||
throw new Error(`Image file too large: ${fileInfo.size} bytes (max: ${maxSize})`);
|
||||
}
|
||||
// Check file size (limit to 20MB to prevent timeouts)
|
||||
const maxSize = 20 * 1024 * 1024; // 20MB
|
||||
if (fileInfo.size && fileInfo.size > maxSize) {
|
||||
throw new Error(`Image file too large: ${fileInfo.size} bytes (max: ${maxSize})`);
|
||||
}
|
||||
|
||||
console.log('Image file info:', { size: fileInfo.size, uri: fileInfo.uri });
|
||||
console.log('Image file info:', { size: fileInfo.size, uri: fileInfo.uri });
|
||||
|
||||
const base64 = await FileSystem.readAsStringAsync(imagePath, {
|
||||
encoding: FileSystem.EncodingType.Base64,
|
||||
});
|
||||
const base64 = await FileSystem.readAsStringAsync(imagePath, {
|
||||
encoding: FileSystem.EncodingType.Base64,
|
||||
});
|
||||
|
||||
console.log('Base64 conversion completed, length:', base64.length);
|
||||
return base64;
|
||||
} catch (error) {
|
||||
console.error('Failed to convert image to base64:', error);
|
||||
throw new Error(`Failed to convert image to base64: ${error}`);
|
||||
}
|
||||
}
|
||||
console.log('Base64 conversion completed, length:', base64.length);
|
||||
return base64;
|
||||
} catch (error) {
|
||||
console.error('Failed to convert image to base64:', error);
|
||||
throw new Error(`Failed to convert image to base64: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the optimized prompt based on context
|
||||
*/
|
||||
private generatePrompt(context?: PromptContext): string {
|
||||
const basePrompt = `Du bist ein professioneller Ernährungsexperte. Analysiere dieses Essen-Foto präzise und detailliert.
|
||||
/**
|
||||
* Generates the optimized prompt based on context
|
||||
*/
|
||||
private generatePrompt(context?: PromptContext): string {
|
||||
const basePrompt = `Du bist ein professioneller Ernährungsexperte. Analysiere dieses Essen-Foto präzise und detailliert.
|
||||
|
||||
AUFGABE:
|
||||
1. Erkenne alle sichtbaren Lebensmittel und schätze realistische Portionsgrößen
|
||||
|
|
@ -194,347 +194,347 @@ WICHTIG:
|
|||
- Versteckte Fette/Öle nicht vergessen
|
||||
- Mehrere gleiche Items separat listen`;
|
||||
|
||||
return basePrompt;
|
||||
}
|
||||
return basePrompt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds contextual information to the prompt
|
||||
*/
|
||||
private getContextualPrompt(context?: PromptContext): string {
|
||||
if (!context) return '';
|
||||
/**
|
||||
* Adds contextual information to the prompt
|
||||
*/
|
||||
private getContextualPrompt(context?: PromptContext): string {
|
||||
if (!context) return '';
|
||||
|
||||
const contextPrompts = {
|
||||
breakfast: 'KONTEXT: Frühstück - berücksichtige typische deutsche Frühstücksportionen',
|
||||
lunch: 'KONTEXT: Mittagessen - Standard-Portionsgrößen für Hauptmahlzeit',
|
||||
dinner: 'KONTEXT: Abendessen - oft größere Portionen, mehr Kohlenhydrate',
|
||||
snack: 'KONTEXT: Snack - kleinere Portionen, oft verarbeitete Lebensmittel',
|
||||
restaurant: 'KONTEXT: Restaurant - größere Portionen, mehr versteckte Fette wahrscheinlich',
|
||||
homemade: 'KONTEXT: Hausgemacht - tendenziell gesünder, weniger versteckte Zusätze',
|
||||
fastfood: 'KONTEXT: Fast Food - höhere Kaloriendichte, mehr verarbeitete Zutaten',
|
||||
};
|
||||
const contextPrompts = {
|
||||
breakfast: 'KONTEXT: Frühstück - berücksichtige typische deutsche Frühstücksportionen',
|
||||
lunch: 'KONTEXT: Mittagessen - Standard-Portionsgrößen für Hauptmahlzeit',
|
||||
dinner: 'KONTEXT: Abendessen - oft größere Portionen, mehr Kohlenhydrate',
|
||||
snack: 'KONTEXT: Snack - kleinere Portionen, oft verarbeitete Lebensmittel',
|
||||
restaurant: 'KONTEXT: Restaurant - größere Portionen, mehr versteckte Fette wahrscheinlich',
|
||||
homemade: 'KONTEXT: Hausgemacht - tendenziell gesünder, weniger versteckte Zusätze',
|
||||
fastfood: 'KONTEXT: Fast Food - höhere Kaloriendichte, mehr verarbeitete Zutaten',
|
||||
};
|
||||
|
||||
const contextStrings: string[] = [];
|
||||
const contextStrings: string[] = [];
|
||||
|
||||
if (context.mealType) {
|
||||
contextStrings.push(contextPrompts[context.mealType] || '');
|
||||
}
|
||||
if (context.mealType) {
|
||||
contextStrings.push(contextPrompts[context.mealType] || '');
|
||||
}
|
||||
|
||||
if (context.location) {
|
||||
contextStrings.push(contextPrompts[context.location] || '');
|
||||
}
|
||||
if (context.location) {
|
||||
contextStrings.push(contextPrompts[context.location] || '');
|
||||
}
|
||||
|
||||
if (context.additional) {
|
||||
contextStrings.push(`ZUSÄTZLICHER KONTEXT: ${context.additional}`);
|
||||
}
|
||||
if (context.additional) {
|
||||
contextStrings.push(`ZUSÄTZLICHER KONTEXT: ${context.additional}`);
|
||||
}
|
||||
|
||||
return contextStrings.filter(Boolean).join('\n');
|
||||
}
|
||||
return contextStrings.filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements exponential backoff retry logic
|
||||
*/
|
||||
private async retry<T>(operation: () => Promise<T>, attempt: number = 0): Promise<T> {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
if (attempt >= this.retryConfig.maxRetries) {
|
||||
throw error;
|
||||
}
|
||||
/**
|
||||
* Implements exponential backoff retry logic
|
||||
*/
|
||||
private async retry<T>(operation: () => Promise<T>, attempt: number = 0): Promise<T> {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
if (attempt >= this.retryConfig.maxRetries) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const delay = Math.min(
|
||||
this.retryConfig.baseDelay * Math.pow(this.retryConfig.backoffMultiplier, attempt),
|
||||
this.retryConfig.maxDelay
|
||||
);
|
||||
const delay = Math.min(
|
||||
this.retryConfig.baseDelay * Math.pow(this.retryConfig.backoffMultiplier, attempt),
|
||||
this.retryConfig.maxDelay
|
||||
);
|
||||
|
||||
console.log(
|
||||
`Gemini API call failed, retrying in ${delay}ms (attempt ${attempt + 1}/${this.retryConfig.maxRetries})`
|
||||
);
|
||||
console.log(
|
||||
`Gemini API call failed, retrying in ${delay}ms (attempt ${attempt + 1}/${this.retryConfig.maxRetries})`
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
return this.retry(operation, attempt + 1);
|
||||
}
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
return this.retry(operation, attempt + 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates and parses the Gemini API response
|
||||
*/
|
||||
private validateResponse(response: string): GeminiAnalysisResult {
|
||||
try {
|
||||
console.log('Raw Gemini response:', response.substring(0, 500) + '...');
|
||||
/**
|
||||
* Validates and parses the Gemini API response
|
||||
*/
|
||||
private validateResponse(response: string): GeminiAnalysisResult {
|
||||
try {
|
||||
console.log('Raw Gemini response:', response.substring(0, 500) + '...');
|
||||
|
||||
// Clean the response - remove any markdown formatting
|
||||
const cleanResponse = response
|
||||
.replace(/```json\n?/g, '')
|
||||
.replace(/```\n?/g, '')
|
||||
.trim();
|
||||
// Clean the response - remove any markdown formatting
|
||||
const cleanResponse = response
|
||||
.replace(/```json\n?/g, '')
|
||||
.replace(/```\n?/g, '')
|
||||
.trim();
|
||||
|
||||
console.log('Cleaned response:', cleanResponse.substring(0, 500) + '...');
|
||||
console.log('Cleaned response:', cleanResponse.substring(0, 500) + '...');
|
||||
|
||||
const parsed = JSON.parse(cleanResponse);
|
||||
console.log('Parsed JSON structure:', {
|
||||
hasMealAnalysis: !!parsed.meal_analysis,
|
||||
hasFoodItems: !!parsed.food_items,
|
||||
hasAnalysisNotes: !!parsed.analysis_notes,
|
||||
mealAnalysisFields: parsed.meal_analysis ? Object.keys(parsed.meal_analysis) : [],
|
||||
});
|
||||
const parsed = JSON.parse(cleanResponse);
|
||||
console.log('Parsed JSON structure:', {
|
||||
hasMealAnalysis: !!parsed.meal_analysis,
|
||||
hasFoodItems: !!parsed.food_items,
|
||||
hasAnalysisNotes: !!parsed.analysis_notes,
|
||||
mealAnalysisFields: parsed.meal_analysis ? Object.keys(parsed.meal_analysis) : [],
|
||||
});
|
||||
|
||||
// Validate required fields
|
||||
if (!parsed.meal_analysis || !parsed.food_items || !parsed.analysis_notes) {
|
||||
throw new Error('Missing required fields in API response');
|
||||
}
|
||||
// Validate required fields
|
||||
if (!parsed.meal_analysis || !parsed.food_items || !parsed.analysis_notes) {
|
||||
throw new Error('Missing required fields in API response');
|
||||
}
|
||||
|
||||
// Validate meal_analysis structure with fallbacks
|
||||
const mealAnalysis = parsed.meal_analysis;
|
||||
// Validate meal_analysis structure with fallbacks
|
||||
const mealAnalysis = parsed.meal_analysis;
|
||||
|
||||
// Set defaults for missing fields
|
||||
if (mealAnalysis.health_score === undefined || mealAnalysis.health_score === null) {
|
||||
console.warn('health_score missing, setting default value');
|
||||
mealAnalysis.health_score = 5.0; // Default neutral score
|
||||
}
|
||||
// Set defaults for missing fields
|
||||
if (mealAnalysis.health_score === undefined || mealAnalysis.health_score === null) {
|
||||
console.warn('health_score missing, setting default value');
|
||||
mealAnalysis.health_score = 5.0; // Default neutral score
|
||||
}
|
||||
|
||||
if (mealAnalysis.health_category === undefined || mealAnalysis.health_category === null) {
|
||||
console.warn('health_category missing, setting default value');
|
||||
mealAnalysis.health_category = 'moderate';
|
||||
}
|
||||
if (mealAnalysis.health_category === undefined || mealAnalysis.health_category === null) {
|
||||
console.warn('health_category missing, setting default value');
|
||||
mealAnalysis.health_category = 'moderate';
|
||||
}
|
||||
|
||||
if (mealAnalysis.confidence === undefined || mealAnalysis.confidence === null) {
|
||||
console.warn('confidence missing, setting default value');
|
||||
mealAnalysis.confidence = 0.7; // Default medium confidence
|
||||
}
|
||||
if (mealAnalysis.confidence === undefined || mealAnalysis.confidence === null) {
|
||||
console.warn('confidence missing, setting default value');
|
||||
mealAnalysis.confidence = 0.7; // Default medium confidence
|
||||
}
|
||||
|
||||
// Ensure required numerical fields exist
|
||||
const requiredNumericalFields = [
|
||||
'total_calories',
|
||||
'total_protein',
|
||||
'total_carbs',
|
||||
'total_fat',
|
||||
];
|
||||
for (const field of requiredNumericalFields) {
|
||||
if (mealAnalysis[field] === undefined || mealAnalysis[field] === null) {
|
||||
console.warn(`${field} missing, setting to 0`);
|
||||
mealAnalysis[field] = 0;
|
||||
}
|
||||
}
|
||||
// Ensure required numerical fields exist
|
||||
const requiredNumericalFields = [
|
||||
'total_calories',
|
||||
'total_protein',
|
||||
'total_carbs',
|
||||
'total_fat',
|
||||
];
|
||||
for (const field of requiredNumericalFields) {
|
||||
if (mealAnalysis[field] === undefined || mealAnalysis[field] === null) {
|
||||
console.warn(`${field} missing, setting to 0`);
|
||||
mealAnalysis[field] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate health_score range
|
||||
if (mealAnalysis.health_score < 1 || mealAnalysis.health_score > 10) {
|
||||
console.warn('Health score out of range, clamping to 1-10');
|
||||
mealAnalysis.health_score = Math.max(1, Math.min(10, mealAnalysis.health_score));
|
||||
}
|
||||
// Validate health_score range
|
||||
if (mealAnalysis.health_score < 1 || mealAnalysis.health_score > 10) {
|
||||
console.warn('Health score out of range, clamping to 1-10');
|
||||
mealAnalysis.health_score = Math.max(1, Math.min(10, mealAnalysis.health_score));
|
||||
}
|
||||
|
||||
// Validate confidence range
|
||||
if (mealAnalysis.confidence < 0 || mealAnalysis.confidence > 1) {
|
||||
console.warn('Confidence out of range, clamping to 0-1');
|
||||
mealAnalysis.confidence = Math.max(0, Math.min(1, mealAnalysis.confidence));
|
||||
}
|
||||
// Validate confidence range
|
||||
if (mealAnalysis.confidence < 0 || mealAnalysis.confidence > 1) {
|
||||
console.warn('Confidence out of range, clamping to 0-1');
|
||||
mealAnalysis.confidence = Math.max(0, Math.min(1, mealAnalysis.confidence));
|
||||
}
|
||||
|
||||
// Ensure food_items is an array
|
||||
if (!Array.isArray(parsed.food_items)) {
|
||||
console.warn('food_items is not an array, creating empty array');
|
||||
parsed.food_items = [];
|
||||
}
|
||||
// Ensure food_items is an array
|
||||
if (!Array.isArray(parsed.food_items)) {
|
||||
console.warn('food_items is not an array, creating empty array');
|
||||
parsed.food_items = [];
|
||||
}
|
||||
|
||||
console.log('Response validation successful');
|
||||
return parsed as GeminiAnalysisResult;
|
||||
} catch (error) {
|
||||
console.error('Full response that failed to parse:', response);
|
||||
throw new Error(`Failed to parse Gemini response: ${error}`);
|
||||
}
|
||||
}
|
||||
console.log('Response validation successful');
|
||||
return parsed as GeminiAnalysisResult;
|
||||
} catch (error) {
|
||||
console.error('Full response that failed to parse:', response);
|
||||
throw new Error(`Failed to parse Gemini response: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main method to analyze food image
|
||||
*/
|
||||
public async analyzeFoodImage(
|
||||
imagePath: string,
|
||||
context?: PromptContext
|
||||
): Promise<GeminiAnalysisResult> {
|
||||
if (!this.model) {
|
||||
console.error('GeminiService not properly initialized');
|
||||
console.log('Attempting re-initialization...');
|
||||
this.initialize();
|
||||
/**
|
||||
* Main method to analyze food image
|
||||
*/
|
||||
public async analyzeFoodImage(
|
||||
imagePath: string,
|
||||
context?: PromptContext
|
||||
): Promise<GeminiAnalysisResult> {
|
||||
if (!this.model) {
|
||||
console.error('GeminiService not properly initialized');
|
||||
console.log('Attempting re-initialization...');
|
||||
this.initialize();
|
||||
|
||||
if (!this.model) {
|
||||
throw new GeminiError(
|
||||
'GeminiService not initialized. Check API key: EXPO_PUBLIC_GEMINI_API_KEY',
|
||||
'INITIALIZATION_ERROR',
|
||||
'PERMANENT'
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!this.model) {
|
||||
throw new GeminiError(
|
||||
'GeminiService not initialized. Check API key: EXPO_PUBLIC_GEMINI_API_KEY',
|
||||
'INITIALIZATION_ERROR',
|
||||
'PERMANENT'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
console.log('Starting Gemini food analysis...');
|
||||
console.log('Analysis parameters:', {
|
||||
imagePath,
|
||||
context,
|
||||
timeout: this.requestTimeout,
|
||||
maxRetries: this.retryConfig.maxRetries,
|
||||
});
|
||||
try {
|
||||
console.log('Starting Gemini food analysis...');
|
||||
console.log('Analysis parameters:', {
|
||||
imagePath,
|
||||
context,
|
||||
timeout: this.requestTimeout,
|
||||
maxRetries: this.retryConfig.maxRetries,
|
||||
});
|
||||
|
||||
// Convert image to base64
|
||||
console.log('Step 1: Converting image to base64...');
|
||||
const base64Image = await this.imageToBase64(imagePath);
|
||||
console.log('Step 1 completed: Base64 conversion successful');
|
||||
// Convert image to base64
|
||||
console.log('Step 1: Converting image to base64...');
|
||||
const base64Image = await this.imageToBase64(imagePath);
|
||||
console.log('Step 1 completed: Base64 conversion successful');
|
||||
|
||||
// Generate prompt
|
||||
console.log('Step 2: Generating prompt...');
|
||||
const prompt = this.generatePrompt(context);
|
||||
console.log('Step 2 completed: Prompt generation successful, length:', prompt.length);
|
||||
// Generate prompt
|
||||
console.log('Step 2: Generating prompt...');
|
||||
const prompt = this.generatePrompt(context);
|
||||
console.log('Step 2 completed: Prompt generation successful, length:', prompt.length);
|
||||
|
||||
// Call Gemini API with retry logic
|
||||
console.log('Step 3: Making Gemini API request...');
|
||||
const result = await this.retry(async () => {
|
||||
console.log('Making Gemini API request with timeout:', this.requestTimeout);
|
||||
const requestStartTime = Date.now();
|
||||
// Call Gemini API with retry logic
|
||||
console.log('Step 3: Making Gemini API request...');
|
||||
const result = await this.retry(async () => {
|
||||
console.log('Making Gemini API request with timeout:', this.requestTimeout);
|
||||
const requestStartTime = Date.now();
|
||||
|
||||
// Create timeout promise
|
||||
const timeoutPromise = new Promise((_, reject) => {
|
||||
setTimeout(() => {
|
||||
reject(new Error(`Request timeout after ${this.requestTimeout}ms`));
|
||||
}, this.requestTimeout);
|
||||
});
|
||||
// Create timeout promise
|
||||
const timeoutPromise = new Promise((_, reject) => {
|
||||
setTimeout(() => {
|
||||
reject(new Error(`Request timeout after ${this.requestTimeout}ms`));
|
||||
}, this.requestTimeout);
|
||||
});
|
||||
|
||||
// Race between API call and timeout
|
||||
const response = await Promise.race([
|
||||
this.model.generateContent([
|
||||
prompt,
|
||||
{
|
||||
inlineData: {
|
||||
data: base64Image,
|
||||
mimeType: 'image/jpeg',
|
||||
},
|
||||
},
|
||||
]),
|
||||
timeoutPromise,
|
||||
]);
|
||||
// Race between API call and timeout
|
||||
const response = await Promise.race([
|
||||
this.model.generateContent([
|
||||
prompt,
|
||||
{
|
||||
inlineData: {
|
||||
data: base64Image,
|
||||
mimeType: 'image/jpeg',
|
||||
},
|
||||
},
|
||||
]),
|
||||
timeoutPromise,
|
||||
]);
|
||||
|
||||
const requestDuration = Date.now() - requestStartTime;
|
||||
console.log('Gemini API request completed in:', requestDuration, 'ms');
|
||||
const requestDuration = Date.now() - requestStartTime;
|
||||
console.log('Gemini API request completed in:', requestDuration, 'ms');
|
||||
|
||||
if (!response || !response.response) {
|
||||
throw new Error('Invalid response structure from Gemini API');
|
||||
}
|
||||
if (!response || !response.response) {
|
||||
throw new Error('Invalid response structure from Gemini API');
|
||||
}
|
||||
|
||||
const text = response.response.text();
|
||||
if (!text) {
|
||||
throw new Error('Empty response from Gemini API');
|
||||
}
|
||||
const text = response.response.text();
|
||||
if (!text) {
|
||||
throw new Error('Empty response from Gemini API');
|
||||
}
|
||||
|
||||
console.log('Gemini API response received, length:', text.length);
|
||||
return text;
|
||||
});
|
||||
console.log('Step 3 completed: API request successful');
|
||||
console.log('Gemini API response received, length:', text.length);
|
||||
return text;
|
||||
});
|
||||
console.log('Step 3 completed: API request successful');
|
||||
|
||||
// Validate and parse response
|
||||
console.log('Step 4: Validating and parsing response...');
|
||||
const analysisResult = this.validateResponse(result);
|
||||
console.log('Step 4 completed: Response validation successful');
|
||||
// Validate and parse response
|
||||
console.log('Step 4: Validating and parsing response...');
|
||||
const analysisResult = this.validateResponse(result);
|
||||
console.log('Step 4 completed: Response validation successful');
|
||||
|
||||
const processingTime = Date.now() - startTime;
|
||||
console.log(`Gemini analysis completed successfully in ${processingTime}ms`);
|
||||
const processingTime = Date.now() - startTime;
|
||||
console.log(`Gemini analysis completed successfully in ${processingTime}ms`);
|
||||
|
||||
return {
|
||||
...analysisResult,
|
||||
_metadata: {
|
||||
processingTime,
|
||||
apiProvider: 'gemini',
|
||||
model: this.config.model,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
const processingTime = Date.now() - startTime;
|
||||
console.error('Gemini analysis failed:', error);
|
||||
return {
|
||||
...analysisResult,
|
||||
_metadata: {
|
||||
processingTime,
|
||||
apiProvider: 'gemini',
|
||||
model: this.config.model,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
const processingTime = Date.now() - startTime;
|
||||
console.error('Gemini analysis failed:', error);
|
||||
|
||||
throw new GeminiError(
|
||||
`Analysis failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
this.categorizeError(error),
|
||||
this.isRetryableError(error) ? 'TEMPORARY' : 'PERMANENT',
|
||||
{
|
||||
processingTime,
|
||||
originalError: error instanceof Error ? error.message : String(error),
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
throw new GeminiError(
|
||||
`Analysis failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
this.categorizeError(error),
|
||||
this.isRetryableError(error) ? 'TEMPORARY' : 'PERMANENT',
|
||||
{
|
||||
processingTime,
|
||||
originalError: error instanceof Error ? error.message : String(error),
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Categorizes errors for better handling
|
||||
*/
|
||||
private categorizeError(error: any): string {
|
||||
if (!error) return 'UNKNOWN_ERROR';
|
||||
/**
|
||||
* Categorizes errors for better handling
|
||||
*/
|
||||
private categorizeError(error: any): string {
|
||||
if (!error) return 'UNKNOWN_ERROR';
|
||||
|
||||
const message = error.message || error.toString();
|
||||
const message = error.message || error.toString();
|
||||
|
||||
if (message.includes('API key')) return 'API_KEY_ERROR';
|
||||
if (message.includes('quota') || message.includes('limit')) return 'QUOTA_ERROR';
|
||||
if (message.includes('timeout') || message.includes('aborted') || message.includes('TIMEOUT'))
|
||||
return 'TIMEOUT_ERROR';
|
||||
if (message.includes('network') || message.includes('fetch')) return 'NETWORK_ERROR';
|
||||
if (message.includes('base64') || message.includes('image') || message.includes('too large'))
|
||||
return 'IMAGE_ERROR';
|
||||
if (message.includes('parse') || message.includes('JSON')) return 'PARSING_ERROR';
|
||||
if (message.includes('Invalid response structure')) return 'RESPONSE_ERROR';
|
||||
if (message.includes('API key')) return 'API_KEY_ERROR';
|
||||
if (message.includes('quota') || message.includes('limit')) return 'QUOTA_ERROR';
|
||||
if (message.includes('timeout') || message.includes('aborted') || message.includes('TIMEOUT'))
|
||||
return 'TIMEOUT_ERROR';
|
||||
if (message.includes('network') || message.includes('fetch')) return 'NETWORK_ERROR';
|
||||
if (message.includes('base64') || message.includes('image') || message.includes('too large'))
|
||||
return 'IMAGE_ERROR';
|
||||
if (message.includes('parse') || message.includes('JSON')) return 'PARSING_ERROR';
|
||||
if (message.includes('Invalid response structure')) return 'RESPONSE_ERROR';
|
||||
|
||||
return 'API_ERROR';
|
||||
}
|
||||
return 'API_ERROR';
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if an error is retryable
|
||||
*/
|
||||
private isRetryableError(error: any): boolean {
|
||||
if (!error) return false;
|
||||
/**
|
||||
* Determines if an error is retryable
|
||||
*/
|
||||
private isRetryableError(error: any): boolean {
|
||||
if (!error) return false;
|
||||
|
||||
const message = error.message || error.toString();
|
||||
const message = error.message || error.toString();
|
||||
|
||||
// Don't retry these errors
|
||||
if (message.includes('API key')) return false;
|
||||
if (message.includes('quota exceeded')) return false;
|
||||
if (message.includes('base64')) return false;
|
||||
if (message.includes('file not found')) return false;
|
||||
if (message.includes('too large')) return false;
|
||||
if (message.includes('Invalid response structure')) return false;
|
||||
// Don't retry these errors
|
||||
if (message.includes('API key')) return false;
|
||||
if (message.includes('quota exceeded')) return false;
|
||||
if (message.includes('base64')) return false;
|
||||
if (message.includes('file not found')) return false;
|
||||
if (message.includes('too large')) return false;
|
||||
if (message.includes('Invalid response structure')) return false;
|
||||
|
||||
// Retry these errors (but with caution for timeouts)
|
||||
if (message.includes('network')) return true;
|
||||
if (message.includes('timeout') || message.includes('aborted')) {
|
||||
console.log('Timeout detected - will retry with exponential backoff');
|
||||
return true;
|
||||
}
|
||||
if (message.includes('500')) return true;
|
||||
if (message.includes('502')) return true;
|
||||
if (message.includes('503')) return true;
|
||||
// Retry these errors (but with caution for timeouts)
|
||||
if (message.includes('network')) return true;
|
||||
if (message.includes('timeout') || message.includes('aborted')) {
|
||||
console.log('Timeout detected - will retry with exponential backoff');
|
||||
return true;
|
||||
}
|
||||
if (message.includes('500')) return true;
|
||||
if (message.includes('502')) return true;
|
||||
if (message.includes('503')) return true;
|
||||
|
||||
return false; // Default to non-retryable for unknown errors to prevent infinite loops
|
||||
}
|
||||
return false; // Default to non-retryable for unknown errors to prevent infinite loops
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets service status and configuration
|
||||
*/
|
||||
public getStatus() {
|
||||
return {
|
||||
initialized: !!this.model,
|
||||
hasApiKey: !!this.config.apiKey,
|
||||
model: this.config.model,
|
||||
retryConfig: this.retryConfig,
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Gets service status and configuration
|
||||
*/
|
||||
public getStatus() {
|
||||
return {
|
||||
initialized: !!this.model,
|
||||
hasApiKey: !!this.config.apiKey,
|
||||
model: this.config.model,
|
||||
retryConfig: this.retryConfig,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates retry configuration
|
||||
*/
|
||||
public updateRetryConfig(newConfig: Partial<RetryConfig>) {
|
||||
this.retryConfig = { ...this.retryConfig, ...newConfig };
|
||||
}
|
||||
/**
|
||||
* Updates retry configuration
|
||||
*/
|
||||
public updateRetryConfig(newConfig: Partial<RetryConfig>) {
|
||||
this.retryConfig = { ...this.retryConfig, ...newConfig };
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates request timeout
|
||||
*/
|
||||
public updateTimeout(timeout: number) {
|
||||
this.requestTimeout = timeout;
|
||||
console.log('Updated request timeout to:', timeout);
|
||||
}
|
||||
/**
|
||||
* Updates request timeout
|
||||
*/
|
||||
public updateTimeout(timeout: number) {
|
||||
this.requestTimeout = timeout;
|
||||
console.log('Updated request timeout to:', timeout);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,426 +14,427 @@ const APP_ID = process.env.EXPO_PUBLIC_MIDDLEWARE_APP_ID || 'nutriphi';
|
|||
* Get device information for authentication
|
||||
*/
|
||||
function getDeviceInfo() {
|
||||
return {
|
||||
deviceId: Application.getIosIdForVendorAsync ?
|
||||
Application.androidId || `${Platform.OS}-${Date.now()}` :
|
||||
`${Platform.OS}-${Date.now()}`,
|
||||
deviceName: Device.deviceName || `${Device.brand} ${Device.modelName}`,
|
||||
deviceType: Device.isDevice ? 'mobile' : 'simulator',
|
||||
platform: Platform.OS,
|
||||
};
|
||||
return {
|
||||
deviceId: Application.getIosIdForVendorAsync
|
||||
? Application.androidId || `${Platform.OS}-${Date.now()}`
|
||||
: `${Platform.OS}-${Date.now()}`,
|
||||
deviceName: Device.deviceName || `${Device.brand} ${Device.modelName}`,
|
||||
deviceType: Device.isDevice ? 'mobile' : 'simulator',
|
||||
platform: Platform.OS,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode JWT token
|
||||
*/
|
||||
function decodeToken(token: string): any | null {
|
||||
try {
|
||||
const base64Url = token.split('.')[1];
|
||||
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
||||
// Use atob equivalent for React Native
|
||||
const payload = JSON.parse(
|
||||
decodeURIComponent(
|
||||
Array.from(atob(base64))
|
||||
.map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
|
||||
.join('')
|
||||
)
|
||||
);
|
||||
return payload;
|
||||
} catch (error) {
|
||||
console.error('Error decoding token:', error);
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const base64Url = token.split('.')[1];
|
||||
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
||||
// Use atob equivalent for React Native
|
||||
const payload = JSON.parse(
|
||||
decodeURIComponent(
|
||||
Array.from(atob(base64))
|
||||
.map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
|
||||
.join('')
|
||||
)
|
||||
);
|
||||
return payload;
|
||||
} catch (error) {
|
||||
console.error('Error decoding token:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if token is expired
|
||||
*/
|
||||
function isTokenExpired(token: string): boolean {
|
||||
try {
|
||||
const payload = decodeToken(token);
|
||||
if (!payload || !payload.exp) return true;
|
||||
try {
|
||||
const payload = decodeToken(token);
|
||||
if (!payload || !payload.exp) return true;
|
||||
|
||||
// Add 10 second buffer
|
||||
const bufferTime = 10 * 1000;
|
||||
return Date.now() >= payload.exp * 1000 - bufferTime;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
// Add 10 second buffer
|
||||
const bufferTime = 10 * 1000;
|
||||
return Date.now() >= payload.exp * 1000 - bufferTime;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export interface AuthResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
needsVerification?: boolean;
|
||||
appToken?: string;
|
||||
refreshToken?: string;
|
||||
email?: string;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
needsVerification?: boolean;
|
||||
appToken?: string;
|
||||
refreshToken?: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
export interface UserData {
|
||||
id: string;
|
||||
email: string;
|
||||
role: string;
|
||||
id: string;
|
||||
email: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication service
|
||||
*/
|
||||
export const authService = {
|
||||
/**
|
||||
* Sign in with email and password
|
||||
*/
|
||||
async signIn(email: string, password: string): Promise<AuthResult> {
|
||||
try {
|
||||
const deviceInfo = getDeviceInfo();
|
||||
/**
|
||||
* Sign in with email and password
|
||||
*/
|
||||
async signIn(email: string, password: string): Promise<AuthResult> {
|
||||
try {
|
||||
const deviceInfo = getDeviceInfo();
|
||||
|
||||
const response = await fetch(`${MIDDLEWARE_URL}/auth/signin?appId=${APP_ID}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ email, password, deviceInfo }),
|
||||
});
|
||||
const response = await fetch(`${MIDDLEWARE_URL}/auth/signin?appId=${APP_ID}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ email, password, deviceInfo }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
|
||||
if (response.status === 401) {
|
||||
if (
|
||||
errorData.message?.includes('Firebase user detected') ||
|
||||
errorData.message?.includes('password reset required')
|
||||
) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'FIREBASE_USER_PASSWORD_RESET_REQUIRED',
|
||||
};
|
||||
}
|
||||
if (response.status === 401) {
|
||||
if (
|
||||
errorData.message?.includes('Firebase user detected') ||
|
||||
errorData.message?.includes('password reset required')
|
||||
) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'FIREBASE_USER_PASSWORD_RESET_REQUIRED',
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
errorData.message?.includes('Email not confirmed') ||
|
||||
errorData.message?.includes('Email not verified')
|
||||
) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'EMAIL_NOT_VERIFIED',
|
||||
};
|
||||
}
|
||||
if (
|
||||
errorData.message?.includes('Email not confirmed') ||
|
||||
errorData.message?.includes('Email not verified')
|
||||
) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'EMAIL_NOT_VERIFIED',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: 'INVALID_CREDENTIALS',
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: 'INVALID_CREDENTIALS',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: errorData.message || 'Sign in failed',
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: errorData.message || 'Sign in failed',
|
||||
};
|
||||
}
|
||||
|
||||
const { appToken, refreshToken } = await response.json();
|
||||
const { appToken, refreshToken } = await response.json();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
appToken,
|
||||
refreshToken,
|
||||
email,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error signing in:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error during sign in',
|
||||
};
|
||||
}
|
||||
},
|
||||
return {
|
||||
success: true,
|
||||
appToken,
|
||||
refreshToken,
|
||||
email,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error signing in:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error during sign in',
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Sign up with email and password
|
||||
*/
|
||||
async signUp(email: string, password: string): Promise<AuthResult> {
|
||||
try {
|
||||
const deviceInfo = getDeviceInfo();
|
||||
/**
|
||||
* Sign up with email and password
|
||||
*/
|
||||
async signUp(email: string, password: string): Promise<AuthResult> {
|
||||
try {
|
||||
const deviceInfo = getDeviceInfo();
|
||||
|
||||
const response = await fetch(`${MIDDLEWARE_URL}/auth/signup?appId=${APP_ID}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ email, password, deviceInfo }),
|
||||
});
|
||||
const response = await fetch(`${MIDDLEWARE_URL}/auth/signup?appId=${APP_ID}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ email, password, deviceInfo }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
|
||||
if (response.status === 409) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'This email is already in use',
|
||||
};
|
||||
}
|
||||
if (response.status === 409) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'This email is already in use',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: errorData.message || 'Registration failed',
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: errorData.message || 'Registration failed',
|
||||
};
|
||||
}
|
||||
|
||||
const responseData = await response.json();
|
||||
const responseData = await response.json();
|
||||
|
||||
if (responseData.confirmationRequired) {
|
||||
return {
|
||||
success: true,
|
||||
needsVerification: true,
|
||||
};
|
||||
}
|
||||
if (responseData.confirmationRequired) {
|
||||
return {
|
||||
success: true,
|
||||
needsVerification: true,
|
||||
};
|
||||
}
|
||||
|
||||
const { appToken, refreshToken } = responseData;
|
||||
const { appToken, refreshToken } = responseData;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
appToken,
|
||||
refreshToken,
|
||||
email,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error signing up:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error during registration',
|
||||
};
|
||||
}
|
||||
},
|
||||
return {
|
||||
success: true,
|
||||
appToken,
|
||||
refreshToken,
|
||||
email,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error signing up:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error during registration',
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Sign in with Google ID token
|
||||
*/
|
||||
async signInWithGoogle(idToken: string): Promise<AuthResult> {
|
||||
try {
|
||||
const deviceInfo = getDeviceInfo();
|
||||
/**
|
||||
* Sign in with Google ID token
|
||||
*/
|
||||
async signInWithGoogle(idToken: string): Promise<AuthResult> {
|
||||
try {
|
||||
const deviceInfo = getDeviceInfo();
|
||||
|
||||
const response = await fetch(`${MIDDLEWARE_URL}/auth/google-signin?appId=${APP_ID}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ token: idToken, deviceInfo }),
|
||||
});
|
||||
const response = await fetch(`${MIDDLEWARE_URL}/auth/google-signin?appId=${APP_ID}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ token: idToken, deviceInfo }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
return {
|
||||
success: false,
|
||||
error: errorData.message || 'Google Sign-In failed',
|
||||
};
|
||||
}
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
return {
|
||||
success: false,
|
||||
error: errorData.message || 'Google Sign-In failed',
|
||||
};
|
||||
}
|
||||
|
||||
const responseData = await response.json();
|
||||
const { appToken, refreshToken } = responseData;
|
||||
const responseData = await response.json();
|
||||
const { appToken, refreshToken } = responseData;
|
||||
|
||||
let email = responseData.email;
|
||||
if (!email && appToken) {
|
||||
const payload = decodeToken(appToken);
|
||||
email = payload?.email || payload?.user_metadata?.email || '';
|
||||
}
|
||||
let email = responseData.email;
|
||||
if (!email && appToken) {
|
||||
const payload = decodeToken(appToken);
|
||||
email = payload?.email || payload?.user_metadata?.email || '';
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
appToken,
|
||||
refreshToken,
|
||||
email,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error signing in with Google:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error during Google Sign-In',
|
||||
};
|
||||
}
|
||||
},
|
||||
return {
|
||||
success: true,
|
||||
appToken,
|
||||
refreshToken,
|
||||
email,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error signing in with Google:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error during Google Sign-In',
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Sign in with Apple ID token
|
||||
*/
|
||||
async signInWithApple(idToken: string, user?: { email?: string; fullName?: { givenName?: string; familyName?: string } }): Promise<AuthResult> {
|
||||
try {
|
||||
const deviceInfo = getDeviceInfo();
|
||||
/**
|
||||
* Sign in with Apple ID token
|
||||
*/
|
||||
async signInWithApple(
|
||||
idToken: string,
|
||||
user?: { email?: string; fullName?: { givenName?: string; familyName?: string } }
|
||||
): Promise<AuthResult> {
|
||||
try {
|
||||
const deviceInfo = getDeviceInfo();
|
||||
|
||||
const response = await fetch(`${MIDDLEWARE_URL}/auth/apple-signin?appId=${APP_ID}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ token: idToken, user, deviceInfo }),
|
||||
});
|
||||
const response = await fetch(`${MIDDLEWARE_URL}/auth/apple-signin?appId=${APP_ID}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ token: idToken, user, deviceInfo }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
return {
|
||||
success: false,
|
||||
error: errorData.message || 'Apple Sign-In failed',
|
||||
};
|
||||
}
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
return {
|
||||
success: false,
|
||||
error: errorData.message || 'Apple Sign-In failed',
|
||||
};
|
||||
}
|
||||
|
||||
const responseData = await response.json();
|
||||
const { appToken, refreshToken } = responseData;
|
||||
const responseData = await response.json();
|
||||
const { appToken, refreshToken } = responseData;
|
||||
|
||||
let email = responseData.email || user?.email;
|
||||
if (!email && appToken) {
|
||||
const payload = decodeToken(appToken);
|
||||
email = payload?.email || '';
|
||||
}
|
||||
let email = responseData.email || user?.email;
|
||||
if (!email && appToken) {
|
||||
const payload = decodeToken(appToken);
|
||||
email = payload?.email || '';
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
appToken,
|
||||
refreshToken,
|
||||
email,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error signing in with Apple:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error during Apple Sign-In',
|
||||
};
|
||||
}
|
||||
},
|
||||
return {
|
||||
success: true,
|
||||
appToken,
|
||||
refreshToken,
|
||||
email,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error signing in with Apple:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error during Apple Sign-In',
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Refresh authentication tokens
|
||||
*/
|
||||
async refreshTokens(
|
||||
currentRefreshToken: string
|
||||
): Promise<{
|
||||
appToken: string;
|
||||
refreshToken: string;
|
||||
userData?: UserData | null;
|
||||
}> {
|
||||
try {
|
||||
const deviceInfo = getDeviceInfo();
|
||||
/**
|
||||
* Refresh authentication tokens
|
||||
*/
|
||||
async refreshTokens(currentRefreshToken: string): Promise<{
|
||||
appToken: string;
|
||||
refreshToken: string;
|
||||
userData?: UserData | null;
|
||||
}> {
|
||||
try {
|
||||
const deviceInfo = getDeviceInfo();
|
||||
|
||||
const response = await fetch(`${MIDDLEWARE_URL}/auth/refresh?appId=${APP_ID}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ refreshToken: currentRefreshToken, deviceInfo }),
|
||||
});
|
||||
const response = await fetch(`${MIDDLEWARE_URL}/auth/refresh?appId=${APP_ID}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ refreshToken: currentRefreshToken, deviceInfo }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.message || 'Failed to refresh tokens');
|
||||
}
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.message || 'Failed to refresh tokens');
|
||||
}
|
||||
|
||||
const responseData = await response.json();
|
||||
const { appToken, refreshToken } = responseData;
|
||||
const responseData = await response.json();
|
||||
const { appToken, refreshToken } = responseData;
|
||||
|
||||
if (!appToken || !refreshToken) {
|
||||
throw new Error('Invalid response from token refresh');
|
||||
}
|
||||
if (!appToken || !refreshToken) {
|
||||
throw new Error('Invalid response from token refresh');
|
||||
}
|
||||
|
||||
let userData: UserData | null = null;
|
||||
try {
|
||||
const payload = decodeToken(appToken);
|
||||
if (payload) {
|
||||
userData = {
|
||||
id: payload.sub,
|
||||
email: payload.email || '',
|
||||
role: payload.role || 'user',
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error decoding refreshed token:', error);
|
||||
}
|
||||
let userData: UserData | null = null;
|
||||
try {
|
||||
const payload = decodeToken(appToken);
|
||||
if (payload) {
|
||||
userData = {
|
||||
id: payload.sub,
|
||||
email: payload.email || '',
|
||||
role: payload.role || 'user',
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error decoding refreshed token:', error);
|
||||
}
|
||||
|
||||
return { appToken, refreshToken, userData };
|
||||
} catch (error) {
|
||||
console.error('Error refreshing tokens:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
return { appToken, refreshToken, userData };
|
||||
} catch (error) {
|
||||
console.error('Error refreshing tokens:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Sign out
|
||||
*/
|
||||
async signOut(refreshToken: string): Promise<void> {
|
||||
try {
|
||||
await fetch(`${MIDDLEWARE_URL}/auth/logout`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ refreshToken }),
|
||||
}).catch((err) => console.error('Error logging out on server:', err));
|
||||
} catch (error) {
|
||||
console.error('Error signing out:', error);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Sign out
|
||||
*/
|
||||
async signOut(refreshToken: string): Promise<void> {
|
||||
try {
|
||||
await fetch(`${MIDDLEWARE_URL}/auth/logout`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ refreshToken }),
|
||||
}).catch((err) => console.error('Error logging out on server:', err));
|
||||
} catch (error) {
|
||||
console.error('Error signing out:', error);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Forgot password
|
||||
*/
|
||||
async forgotPassword(email: string): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const response = await fetch(`${MIDDLEWARE_URL}/auth/forgot-password`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
/**
|
||||
* Forgot password
|
||||
*/
|
||||
async forgotPassword(email: string): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const response = await fetch(`${MIDDLEWARE_URL}/auth/forgot-password`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
|
||||
if (errorData.message?.includes('rate limit')) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
'Too many password reset attempts. Please wait a few minutes before trying again.',
|
||||
};
|
||||
}
|
||||
if (errorData.message?.includes('rate limit')) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
'Too many password reset attempts. Please wait a few minutes before trying again.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: errorData.message || 'Password reset failed',
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: errorData.message || 'Password reset failed',
|
||||
};
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error sending password reset email:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error during password reset',
|
||||
};
|
||||
}
|
||||
},
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error sending password reset email:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error during password reset',
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get user data from token
|
||||
*/
|
||||
getUserFromToken(appToken: string): UserData | null {
|
||||
try {
|
||||
const payload = decodeToken(appToken);
|
||||
if (!payload) return null;
|
||||
/**
|
||||
* Get user data from token
|
||||
*/
|
||||
getUserFromToken(appToken: string): UserData | null {
|
||||
try {
|
||||
const payload = decodeToken(appToken);
|
||||
if (!payload) return null;
|
||||
|
||||
return {
|
||||
id: payload.sub,
|
||||
email: payload.email || '',
|
||||
role: payload.role || 'user',
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error getting user from token:', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
return {
|
||||
id: payload.sub,
|
||||
email: payload.email || '',
|
||||
role: payload.role || 'user',
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error getting user from token:', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if token is valid locally (without network call)
|
||||
*/
|
||||
isTokenValidLocally(token: string): boolean {
|
||||
return !isTokenExpired(token);
|
||||
},
|
||||
/**
|
||||
* Check if token is valid locally (without network call)
|
||||
*/
|
||||
isTokenValidLocally(token: string): boolean {
|
||||
return !isTokenExpired(token);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import * as SecureStore from 'expo-secure-store';
|
||||
|
||||
const STORAGE_KEYS = {
|
||||
APP_TOKEN: 'nutriphi_app_token',
|
||||
REFRESH_TOKEN: 'nutriphi_refresh_token',
|
||||
USER_EMAIL: 'nutriphi_user_email',
|
||||
APP_TOKEN: 'nutriphi_app_token',
|
||||
REFRESH_TOKEN: 'nutriphi_refresh_token',
|
||||
USER_EMAIL: 'nutriphi_user_email',
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -11,110 +11,110 @@ const STORAGE_KEYS = {
|
|||
* Uses Expo SecureStore for encrypted storage on device
|
||||
*/
|
||||
export const tokenManager = {
|
||||
/**
|
||||
* Get the app token (JWT)
|
||||
*/
|
||||
async getAppToken(): Promise<string | null> {
|
||||
try {
|
||||
return await SecureStore.getItemAsync(STORAGE_KEYS.APP_TOKEN);
|
||||
} catch (error) {
|
||||
console.error('Error getting app token:', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Get the app token (JWT)
|
||||
*/
|
||||
async getAppToken(): Promise<string | null> {
|
||||
try {
|
||||
return await SecureStore.getItemAsync(STORAGE_KEYS.APP_TOKEN);
|
||||
} catch (error) {
|
||||
console.error('Error getting app token:', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Set the app token
|
||||
*/
|
||||
async setAppToken(token: string): Promise<void> {
|
||||
try {
|
||||
await SecureStore.setItemAsync(STORAGE_KEYS.APP_TOKEN, token);
|
||||
} catch (error) {
|
||||
console.error('Error setting app token:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Set the app token
|
||||
*/
|
||||
async setAppToken(token: string): Promise<void> {
|
||||
try {
|
||||
await SecureStore.setItemAsync(STORAGE_KEYS.APP_TOKEN, token);
|
||||
} catch (error) {
|
||||
console.error('Error setting app token:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the refresh token
|
||||
*/
|
||||
async getRefreshToken(): Promise<string | null> {
|
||||
try {
|
||||
return await SecureStore.getItemAsync(STORAGE_KEYS.REFRESH_TOKEN);
|
||||
} catch (error) {
|
||||
console.error('Error getting refresh token:', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Get the refresh token
|
||||
*/
|
||||
async getRefreshToken(): Promise<string | null> {
|
||||
try {
|
||||
return await SecureStore.getItemAsync(STORAGE_KEYS.REFRESH_TOKEN);
|
||||
} catch (error) {
|
||||
console.error('Error getting refresh token:', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Set the refresh token
|
||||
*/
|
||||
async setRefreshToken(token: string): Promise<void> {
|
||||
try {
|
||||
await SecureStore.setItemAsync(STORAGE_KEYS.REFRESH_TOKEN, token);
|
||||
} catch (error) {
|
||||
console.error('Error setting refresh token:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Set the refresh token
|
||||
*/
|
||||
async setRefreshToken(token: string): Promise<void> {
|
||||
try {
|
||||
await SecureStore.setItemAsync(STORAGE_KEYS.REFRESH_TOKEN, token);
|
||||
} catch (error) {
|
||||
console.error('Error setting refresh token:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the user email
|
||||
*/
|
||||
async getUserEmail(): Promise<string | null> {
|
||||
try {
|
||||
return await SecureStore.getItemAsync(STORAGE_KEYS.USER_EMAIL);
|
||||
} catch (error) {
|
||||
console.error('Error getting user email:', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Get the user email
|
||||
*/
|
||||
async getUserEmail(): Promise<string | null> {
|
||||
try {
|
||||
return await SecureStore.getItemAsync(STORAGE_KEYS.USER_EMAIL);
|
||||
} catch (error) {
|
||||
console.error('Error getting user email:', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Set the user email
|
||||
*/
|
||||
async setUserEmail(email: string): Promise<void> {
|
||||
try {
|
||||
await SecureStore.setItemAsync(STORAGE_KEYS.USER_EMAIL, email);
|
||||
} catch (error) {
|
||||
console.error('Error setting user email:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Set the user email
|
||||
*/
|
||||
async setUserEmail(email: string): Promise<void> {
|
||||
try {
|
||||
await SecureStore.setItemAsync(STORAGE_KEYS.USER_EMAIL, email);
|
||||
} catch (error) {
|
||||
console.error('Error setting user email:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear all tokens (logout)
|
||||
*/
|
||||
async clearTokens(): Promise<void> {
|
||||
try {
|
||||
await Promise.all([
|
||||
SecureStore.deleteItemAsync(STORAGE_KEYS.APP_TOKEN),
|
||||
SecureStore.deleteItemAsync(STORAGE_KEYS.REFRESH_TOKEN),
|
||||
SecureStore.deleteItemAsync(STORAGE_KEYS.USER_EMAIL),
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error('Error clearing tokens:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Clear all tokens (logout)
|
||||
*/
|
||||
async clearTokens(): Promise<void> {
|
||||
try {
|
||||
await Promise.all([
|
||||
SecureStore.deleteItemAsync(STORAGE_KEYS.APP_TOKEN),
|
||||
SecureStore.deleteItemAsync(STORAGE_KEYS.REFRESH_TOKEN),
|
||||
SecureStore.deleteItemAsync(STORAGE_KEYS.USER_EMAIL),
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error('Error clearing tokens:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get Authorization header for API requests
|
||||
*/
|
||||
async getAuthHeader(): Promise<{ Authorization: string } | Record<string, never>> {
|
||||
const token = await this.getAppToken();
|
||||
if (token) {
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
}
|
||||
return {};
|
||||
},
|
||||
/**
|
||||
* Get Authorization header for API requests
|
||||
*/
|
||||
async getAuthHeader(): Promise<{ Authorization: string } | Record<string, never>> {
|
||||
const token = await this.getAppToken();
|
||||
if (token) {
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
}
|
||||
return {};
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if user has tokens stored
|
||||
*/
|
||||
async hasTokens(): Promise<boolean> {
|
||||
const token = await this.getAppToken();
|
||||
return !!token;
|
||||
},
|
||||
/**
|
||||
* Check if user has tokens stored
|
||||
*/
|
||||
async hasTokens(): Promise<boolean> {
|
||||
const token = await this.getAppToken();
|
||||
return !!token;
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,54 +1,54 @@
|
|||
import * as SQLite from 'expo-sqlite';
|
||||
|
||||
export interface Migration {
|
||||
version: number;
|
||||
name: string;
|
||||
up: (db: SQLite.SQLiteDatabase) => Promise<void>;
|
||||
down?: (db: SQLite.SQLiteDatabase) => Promise<void>;
|
||||
version: number;
|
||||
name: string;
|
||||
up: (db: SQLite.SQLiteDatabase) => Promise<void>;
|
||||
down?: (db: SQLite.SQLiteDatabase) => Promise<void>;
|
||||
}
|
||||
|
||||
export class MigrationService {
|
||||
private static instance: MigrationService;
|
||||
private db: SQLite.SQLiteDatabase | null = null;
|
||||
private static instance: MigrationService;
|
||||
private db: SQLite.SQLiteDatabase | null = null;
|
||||
|
||||
private constructor() {}
|
||||
private constructor() {}
|
||||
|
||||
public static getInstance(): MigrationService {
|
||||
if (!MigrationService.instance) {
|
||||
MigrationService.instance = new MigrationService();
|
||||
}
|
||||
return MigrationService.instance;
|
||||
}
|
||||
public static getInstance(): MigrationService {
|
||||
if (!MigrationService.instance) {
|
||||
MigrationService.instance = new MigrationService();
|
||||
}
|
||||
return MigrationService.instance;
|
||||
}
|
||||
|
||||
public setDatabase(db: SQLite.SQLiteDatabase): void {
|
||||
this.db = db;
|
||||
}
|
||||
public setDatabase(db: SQLite.SQLiteDatabase): void {
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
private migrations: Migration[] = [
|
||||
{
|
||||
version: 1,
|
||||
name: 'Initial Schema',
|
||||
up: async (db: SQLite.SQLiteDatabase) => {
|
||||
// Diese Migration ist bereits in SQLiteService.createTables() implementiert
|
||||
// Hier nur als Referenz für zukünftige Migrationen
|
||||
},
|
||||
},
|
||||
{
|
||||
version: 2,
|
||||
name: 'Add indexes for performance',
|
||||
up: async (db: SQLite.SQLiteDatabase) => {
|
||||
await db.execAsync(`
|
||||
private migrations: Migration[] = [
|
||||
{
|
||||
version: 1,
|
||||
name: 'Initial Schema',
|
||||
up: async (db: SQLite.SQLiteDatabase) => {
|
||||
// Diese Migration ist bereits in SQLiteService.createTables() implementiert
|
||||
// Hier nur als Referenz für zukünftige Migrationen
|
||||
},
|
||||
},
|
||||
{
|
||||
version: 2,
|
||||
name: 'Add indexes for performance',
|
||||
up: async (db: SQLite.SQLiteDatabase) => {
|
||||
await db.execAsync(`
|
||||
CREATE INDEX IF NOT EXISTS idx_meals_analysis_status ON meals(analysis_status);
|
||||
CREATE INDEX IF NOT EXISTS idx_meals_health_category ON meals(health_category);
|
||||
CREATE INDEX IF NOT EXISTS idx_food_items_confidence ON food_items(confidence DESC);
|
||||
`);
|
||||
},
|
||||
},
|
||||
{
|
||||
version: 3,
|
||||
name: 'Add user preferences table',
|
||||
up: async (db: SQLite.SQLiteDatabase) => {
|
||||
await db.execAsync(`
|
||||
},
|
||||
},
|
||||
{
|
||||
version: 3,
|
||||
name: 'Add user preferences table',
|
||||
up: async (db: SQLite.SQLiteDatabase) => {
|
||||
await db.execAsync(`
|
||||
CREATE TABLE IF NOT EXISTS user_preferences (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
key TEXT UNIQUE NOT NULL,
|
||||
|
|
@ -58,157 +58,157 @@ export class MigrationService {
|
|||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
`);
|
||||
},
|
||||
},
|
||||
{
|
||||
version: 4,
|
||||
name: 'Add GPS location fields to meals',
|
||||
up: async (db: SQLite.SQLiteDatabase) => {
|
||||
await db.execAsync(`
|
||||
},
|
||||
},
|
||||
{
|
||||
version: 4,
|
||||
name: 'Add GPS location fields to meals',
|
||||
up: async (db: SQLite.SQLiteDatabase) => {
|
||||
await db.execAsync(`
|
||||
ALTER TABLE meals ADD COLUMN latitude REAL;
|
||||
ALTER TABLE meals ADD COLUMN longitude REAL;
|
||||
ALTER TABLE meals ADD COLUMN location_accuracy REAL;
|
||||
`);
|
||||
|
||||
// Create index for geo queries
|
||||
await db.execAsync(`
|
||||
// Create index for geo queries
|
||||
await db.execAsync(`
|
||||
CREATE INDEX IF NOT EXISTS idx_meals_location ON meals(latitude, longitude);
|
||||
`);
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
public async initializeMigrationTable(): Promise<void> {
|
||||
if (!this.db) throw new Error('Database not set');
|
||||
public async initializeMigrationTable(): Promise<void> {
|
||||
if (!this.db) throw new Error('Database not set');
|
||||
|
||||
await this.db.execAsync(`
|
||||
await this.db.execAsync(`
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
applied_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
public async getCurrentVersion(): Promise<number> {
|
||||
if (!this.db) throw new Error('Database not set');
|
||||
public async getCurrentVersion(): Promise<number> {
|
||||
if (!this.db) throw new Error('Database not set');
|
||||
|
||||
try {
|
||||
const result = await this.db.getFirstAsync<{ version: number }>(
|
||||
'SELECT MAX(version) as version FROM schema_migrations'
|
||||
);
|
||||
return result?.version || 0;
|
||||
} catch {
|
||||
// Tabelle existiert noch nicht
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const result = await this.db.getFirstAsync<{ version: number }>(
|
||||
'SELECT MAX(version) as version FROM schema_migrations'
|
||||
);
|
||||
return result?.version || 0;
|
||||
} catch {
|
||||
// Tabelle existiert noch nicht
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
public async runMigrations(): Promise<void> {
|
||||
if (!this.db) throw new Error('Database not set');
|
||||
public async runMigrations(): Promise<void> {
|
||||
if (!this.db) throw new Error('Database not set');
|
||||
|
||||
await this.initializeMigrationTable();
|
||||
const currentVersion = await this.getCurrentVersion();
|
||||
await this.initializeMigrationTable();
|
||||
const currentVersion = await this.getCurrentVersion();
|
||||
|
||||
console.log(`Current database version: ${currentVersion}`);
|
||||
console.log(`Current database version: ${currentVersion}`);
|
||||
|
||||
const pendingMigrations = this.migrations.filter(
|
||||
(migration) => migration.version > currentVersion
|
||||
);
|
||||
const pendingMigrations = this.migrations.filter(
|
||||
(migration) => migration.version > currentVersion
|
||||
);
|
||||
|
||||
if (pendingMigrations.length === 0) {
|
||||
console.log('No pending migrations');
|
||||
return;
|
||||
}
|
||||
if (pendingMigrations.length === 0) {
|
||||
console.log('No pending migrations');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Running ${pendingMigrations.length} migrations...`);
|
||||
console.log(`Running ${pendingMigrations.length} migrations...`);
|
||||
|
||||
for (const migration of pendingMigrations) {
|
||||
try {
|
||||
console.log(`Applying migration ${migration.version}: ${migration.name}`);
|
||||
for (const migration of pendingMigrations) {
|
||||
try {
|
||||
console.log(`Applying migration ${migration.version}: ${migration.name}`);
|
||||
|
||||
await this.db.execAsync('BEGIN TRANSACTION;');
|
||||
await migration.up(this.db);
|
||||
await this.db.execAsync('BEGIN TRANSACTION;');
|
||||
await migration.up(this.db);
|
||||
|
||||
await this.db.runAsync('INSERT INTO schema_migrations (version, name) VALUES (?, ?)', [
|
||||
migration.version,
|
||||
migration.name,
|
||||
]);
|
||||
await this.db.runAsync('INSERT INTO schema_migrations (version, name) VALUES (?, ?)', [
|
||||
migration.version,
|
||||
migration.name,
|
||||
]);
|
||||
|
||||
await this.db.execAsync('COMMIT;');
|
||||
await this.db.execAsync('COMMIT;');
|
||||
|
||||
console.log(`Migration ${migration.version} completed successfully`);
|
||||
} catch (err) {
|
||||
console.error(`Migration ${migration.version} failed:`, err);
|
||||
await this.db.execAsync('ROLLBACK;');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
console.log(`Migration ${migration.version} completed successfully`);
|
||||
} catch (err) {
|
||||
console.error(`Migration ${migration.version} failed:`, err);
|
||||
await this.db.execAsync('ROLLBACK;');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('All migrations completed successfully');
|
||||
}
|
||||
console.log('All migrations completed successfully');
|
||||
}
|
||||
|
||||
public async rollbackToVersion(targetVersion: number): Promise<void> {
|
||||
if (!this.db) throw new Error('Database not set');
|
||||
public async rollbackToVersion(targetVersion: number): Promise<void> {
|
||||
if (!this.db) throw new Error('Database not set');
|
||||
|
||||
const currentVersion = await this.getCurrentVersion();
|
||||
const currentVersion = await this.getCurrentVersion();
|
||||
|
||||
if (targetVersion >= currentVersion) {
|
||||
console.log('Target version is not lower than current version');
|
||||
return;
|
||||
}
|
||||
if (targetVersion >= currentVersion) {
|
||||
console.log('Target version is not lower than current version');
|
||||
return;
|
||||
}
|
||||
|
||||
const migrationsToRollback = this.migrations
|
||||
.filter((m) => m.version > targetVersion && m.version <= currentVersion)
|
||||
.sort((a, b) => b.version - a.version); // Descending order
|
||||
const migrationsToRollback = this.migrations
|
||||
.filter((m) => m.version > targetVersion && m.version <= currentVersion)
|
||||
.sort((a, b) => b.version - a.version); // Descending order
|
||||
|
||||
console.log(`Rolling back ${migrationsToRollback.length} migrations...`);
|
||||
console.log(`Rolling back ${migrationsToRollback.length} migrations...`);
|
||||
|
||||
for (const migration of migrationsToRollback) {
|
||||
if (!migration.down) {
|
||||
console.warn(`No rollback defined for migration ${migration.version}`);
|
||||
continue;
|
||||
}
|
||||
for (const migration of migrationsToRollback) {
|
||||
if (!migration.down) {
|
||||
console.warn(`No rollback defined for migration ${migration.version}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`Rolling back migration ${migration.version}: ${migration.name}`);
|
||||
try {
|
||||
console.log(`Rolling back migration ${migration.version}: ${migration.name}`);
|
||||
|
||||
await this.db.execAsync('BEGIN TRANSACTION;');
|
||||
await migration.down(this.db);
|
||||
await this.db.execAsync('BEGIN TRANSACTION;');
|
||||
await migration.down(this.db);
|
||||
|
||||
await this.db.runAsync('DELETE FROM schema_migrations WHERE version = ?', [
|
||||
migration.version,
|
||||
]);
|
||||
await this.db.runAsync('DELETE FROM schema_migrations WHERE version = ?', [
|
||||
migration.version,
|
||||
]);
|
||||
|
||||
await this.db.execAsync('COMMIT;');
|
||||
await this.db.execAsync('COMMIT;');
|
||||
|
||||
console.log(`Migration ${migration.version} rolled back successfully`);
|
||||
} catch (err) {
|
||||
console.error(`Rollback of migration ${migration.version} failed:`, err);
|
||||
await this.db.execAsync('ROLLBACK;');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
console.log(`Migration ${migration.version} rolled back successfully`);
|
||||
} catch (err) {
|
||||
console.error(`Rollback of migration ${migration.version} failed:`, err);
|
||||
await this.db.execAsync('ROLLBACK;');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Rollback to version ${targetVersion} completed`);
|
||||
}
|
||||
console.log(`Rollback to version ${targetVersion} completed`);
|
||||
}
|
||||
|
||||
public async addMigration(migration: Migration): Promise<void> {
|
||||
// Überprüfe, ob die Version bereits existiert
|
||||
const existingMigration = this.migrations.find((m) => m.version === migration.version);
|
||||
if (existingMigration) {
|
||||
throw new Error(`Migration version ${migration.version} already exists`);
|
||||
}
|
||||
public async addMigration(migration: Migration): Promise<void> {
|
||||
// Überprüfe, ob die Version bereits existiert
|
||||
const existingMigration = this.migrations.find((m) => m.version === migration.version);
|
||||
if (existingMigration) {
|
||||
throw new Error(`Migration version ${migration.version} already exists`);
|
||||
}
|
||||
|
||||
this.migrations.push(migration);
|
||||
this.migrations.sort((a, b) => a.version - b.version);
|
||||
}
|
||||
this.migrations.push(migration);
|
||||
this.migrations.sort((a, b) => a.version - b.version);
|
||||
}
|
||||
|
||||
public getAppliedMigrations(): Promise<{ version: number; name: string; applied_at: string }[]> {
|
||||
if (!this.db) throw new Error('Database not set');
|
||||
public getAppliedMigrations(): Promise<{ version: number; name: string; applied_at: string }[]> {
|
||||
if (!this.db) throw new Error('Database not set');
|
||||
|
||||
return this.db.getAllAsync(
|
||||
'SELECT version, name, applied_at FROM schema_migrations ORDER BY version'
|
||||
);
|
||||
}
|
||||
return this.db.getAllAsync(
|
||||
'SELECT version, name, applied_at FROM schema_migrations ORDER BY version'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,41 +2,41 @@ import * as SQLite from 'expo-sqlite';
|
|||
import { Meal, FoodItem, CreateMealInput, MealWithItems } from '../../types/Database';
|
||||
|
||||
export class SQLiteService {
|
||||
private static instance: SQLiteService;
|
||||
private db: SQLite.SQLiteDatabase | null = null;
|
||||
private static instance: SQLiteService;
|
||||
private db: SQLite.SQLiteDatabase | null = null;
|
||||
|
||||
private constructor() {}
|
||||
private constructor() {}
|
||||
|
||||
public static getInstance(): SQLiteService {
|
||||
if (!SQLiteService.instance) {
|
||||
SQLiteService.instance = new SQLiteService();
|
||||
}
|
||||
return SQLiteService.instance;
|
||||
}
|
||||
public static getInstance(): SQLiteService {
|
||||
if (!SQLiteService.instance) {
|
||||
SQLiteService.instance = new SQLiteService();
|
||||
}
|
||||
return SQLiteService.instance;
|
||||
}
|
||||
|
||||
public async initialize(): Promise<void> {
|
||||
try {
|
||||
this.db = await SQLite.openDatabaseAsync('nutriphi.db');
|
||||
await this.createTables();
|
||||
await this.createIndices();
|
||||
} catch (error) {
|
||||
console.error('Database initialization failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
public async initialize(): Promise<void> {
|
||||
try {
|
||||
this.db = await SQLite.openDatabaseAsync('nutriphi.db');
|
||||
await this.createTables();
|
||||
await this.createIndices();
|
||||
} catch (error) {
|
||||
console.error('Database initialization failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async getDatabase(): Promise<SQLite.SQLiteDatabase> {
|
||||
if (!this.db) {
|
||||
throw new Error('Database not initialized. Call initialize() first.');
|
||||
}
|
||||
return this.db;
|
||||
}
|
||||
public async getDatabase(): Promise<SQLite.SQLiteDatabase> {
|
||||
if (!this.db) {
|
||||
throw new Error('Database not initialized. Call initialize() first.');
|
||||
}
|
||||
return this.db;
|
||||
}
|
||||
|
||||
private async createTables(): Promise<void> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
private async createTables(): Promise<void> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
// Meals Table
|
||||
await this.db.execAsync(`
|
||||
// Meals Table
|
||||
await this.db.execAsync(`
|
||||
CREATE TABLE IF NOT EXISTS meals (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
cloud_id TEXT UNIQUE,
|
||||
|
|
@ -73,8 +73,8 @@ export class SQLiteService {
|
|||
);
|
||||
`);
|
||||
|
||||
// Food Items Table
|
||||
await this.db.execAsync(`
|
||||
// Food Items Table
|
||||
await this.db.execAsync(`
|
||||
CREATE TABLE IF NOT EXISTS food_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
cloud_id TEXT UNIQUE,
|
||||
|
|
@ -100,8 +100,8 @@ export class SQLiteService {
|
|||
);
|
||||
`);
|
||||
|
||||
// Sync Metadata Table
|
||||
await this.db.execAsync(`
|
||||
// Sync Metadata Table
|
||||
await this.db.execAsync(`
|
||||
CREATE TABLE IF NOT EXISTS sync_metadata (
|
||||
table_name TEXT NOT NULL,
|
||||
record_id INTEGER NOT NULL,
|
||||
|
|
@ -113,8 +113,8 @@ export class SQLiteService {
|
|||
);
|
||||
`);
|
||||
|
||||
// User Preferences Table
|
||||
await this.db.execAsync(`
|
||||
// User Preferences Table
|
||||
await this.db.execAsync(`
|
||||
CREATE TABLE IF NOT EXISTS user_preferences (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
key TEXT UNIQUE NOT NULL,
|
||||
|
|
@ -124,12 +124,12 @@ export class SQLiteService {
|
|||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
private async createIndices(): Promise<void> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
private async createIndices(): Promise<void> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
await this.db.execAsync(`
|
||||
await this.db.execAsync(`
|
||||
CREATE INDEX IF NOT EXISTS idx_meals_timestamp ON meals(timestamp DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_meals_sync_status ON meals(sync_status);
|
||||
CREATE INDEX IF NOT EXISTS idx_meals_meal_type ON meals(meal_type);
|
||||
|
|
@ -137,225 +137,225 @@ export class SQLiteService {
|
|||
CREATE INDEX IF NOT EXISTS idx_food_items_category ON food_items(category);
|
||||
CREATE INDEX IF NOT EXISTS idx_sync_metadata_status ON sync_metadata(table_name, last_sync_at);
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
// CRUD Operations für Meals
|
||||
public async createMeal(input: CreateMealInput): Promise<number> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
// CRUD Operations für Meals
|
||||
public async createMeal(input: CreateMealInput): Promise<number> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const dimensions = input.photo_dimensions ? JSON.stringify(input.photo_dimensions) : null;
|
||||
const now = new Date().toISOString();
|
||||
const dimensions = input.photo_dimensions ? JSON.stringify(input.photo_dimensions) : null;
|
||||
|
||||
const result = await this.db.runAsync(
|
||||
`
|
||||
const result = await this.db.runAsync(
|
||||
`
|
||||
INSERT INTO meals (
|
||||
photo_path, photo_size, photo_dimensions, timestamp,
|
||||
created_at, updated_at, meal_type, location, latitude, longitude, location_accuracy,
|
||||
user_notes, analysis_status, api_provider
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
[
|
||||
input.photo_path,
|
||||
input.photo_size || null,
|
||||
dimensions,
|
||||
now,
|
||||
now,
|
||||
now,
|
||||
input.meal_type || null,
|
||||
input.location || null,
|
||||
input.latitude || null,
|
||||
input.longitude || null,
|
||||
input.location_accuracy || null,
|
||||
input.user_notes || null,
|
||||
input.analysis_status || 'pending',
|
||||
input.api_provider || 'gemini',
|
||||
]
|
||||
);
|
||||
[
|
||||
input.photo_path,
|
||||
input.photo_size || null,
|
||||
dimensions,
|
||||
now,
|
||||
now,
|
||||
now,
|
||||
input.meal_type || null,
|
||||
input.location || null,
|
||||
input.latitude || null,
|
||||
input.longitude || null,
|
||||
input.location_accuracy || null,
|
||||
input.user_notes || null,
|
||||
input.analysis_status || 'pending',
|
||||
input.api_provider || 'gemini',
|
||||
]
|
||||
);
|
||||
|
||||
return result.lastInsertRowId;
|
||||
}
|
||||
return result.lastInsertRowId;
|
||||
}
|
||||
|
||||
public async getMealById(id: number): Promise<Meal | null> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
public async getMealById(id: number): Promise<Meal | null> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
const result = await this.db.getFirstAsync<Meal>('SELECT * FROM meals WHERE id = ?', [id]);
|
||||
const result = await this.db.getFirstAsync<Meal>('SELECT * FROM meals WHERE id = ?', [id]);
|
||||
|
||||
return result || null;
|
||||
}
|
||||
return result || null;
|
||||
}
|
||||
|
||||
public async getMealWithItems(id: number): Promise<MealWithItems | null> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
public async getMealWithItems(id: number): Promise<MealWithItems | null> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
const meal = await this.getMealById(id);
|
||||
if (!meal) return null;
|
||||
const meal = await this.getMealById(id);
|
||||
if (!meal) return null;
|
||||
|
||||
const foodItems = await this.db.getAllAsync<FoodItem>(
|
||||
'SELECT * FROM food_items WHERE meal_id = ? ORDER BY created_at',
|
||||
[id]
|
||||
);
|
||||
const foodItems = await this.db.getAllAsync<FoodItem>(
|
||||
'SELECT * FROM food_items WHERE meal_id = ? ORDER BY created_at',
|
||||
[id]
|
||||
);
|
||||
|
||||
return {
|
||||
...meal,
|
||||
food_items: foodItems,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...meal,
|
||||
food_items: foodItems,
|
||||
};
|
||||
}
|
||||
|
||||
public async getAllMeals(limit: number = 50, offset: number = 0): Promise<Meal[]> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
public async getAllMeals(limit: number = 50, offset: number = 0): Promise<Meal[]> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
return await this.db.getAllAsync<Meal>(
|
||||
'SELECT * FROM meals ORDER BY timestamp DESC LIMIT ? OFFSET ?',
|
||||
[limit, offset]
|
||||
);
|
||||
}
|
||||
return await this.db.getAllAsync<Meal>(
|
||||
'SELECT * FROM meals ORDER BY timestamp DESC LIMIT ? OFFSET ?',
|
||||
[limit, offset]
|
||||
);
|
||||
}
|
||||
|
||||
public async getAllMealsWithItems(
|
||||
limit: number = 50,
|
||||
offset: number = 0
|
||||
): Promise<MealWithItems[]> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
public async getAllMealsWithItems(
|
||||
limit: number = 50,
|
||||
offset: number = 0
|
||||
): Promise<MealWithItems[]> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
const meals = await this.getAllMeals(limit, offset);
|
||||
const mealsWithItems: MealWithItems[] = [];
|
||||
const meals = await this.getAllMeals(limit, offset);
|
||||
const mealsWithItems: MealWithItems[] = [];
|
||||
|
||||
for (const meal of meals) {
|
||||
const foodItems = await this.db.getAllAsync<FoodItem>(
|
||||
'SELECT * FROM food_items WHERE meal_id = ? ORDER BY created_at',
|
||||
[meal.id!]
|
||||
);
|
||||
for (const meal of meals) {
|
||||
const foodItems = await this.db.getAllAsync<FoodItem>(
|
||||
'SELECT * FROM food_items WHERE meal_id = ? ORDER BY created_at',
|
||||
[meal.id!]
|
||||
);
|
||||
|
||||
mealsWithItems.push({
|
||||
...meal,
|
||||
food_items: foodItems,
|
||||
});
|
||||
}
|
||||
mealsWithItems.push({
|
||||
...meal,
|
||||
food_items: foodItems,
|
||||
});
|
||||
}
|
||||
|
||||
return mealsWithItems;
|
||||
}
|
||||
return mealsWithItems;
|
||||
}
|
||||
|
||||
public async updateMeal(id: number, updates: Partial<Meal>): Promise<void> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
public async updateMeal(id: number, updates: Partial<Meal>): Promise<void> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
const updateFields = Object.keys(updates).filter((key) => key !== 'id');
|
||||
const updateValues = updateFields.map((key) => updates[key as keyof Meal]);
|
||||
const updateFields = Object.keys(updates).filter((key) => key !== 'id');
|
||||
const updateValues = updateFields.map((key) => updates[key as keyof Meal]);
|
||||
|
||||
const setClause = updateFields.map((key) => `${key} = ?`).join(', ');
|
||||
const setClause = updateFields.map((key) => `${key} = ?`).join(', ');
|
||||
|
||||
await this.db.runAsync(
|
||||
`
|
||||
await this.db.runAsync(
|
||||
`
|
||||
UPDATE meals SET ${setClause}, updated_at = datetime('now') WHERE id = ?
|
||||
`,
|
||||
[...updateValues, id]
|
||||
);
|
||||
}
|
||||
[...updateValues, id]
|
||||
);
|
||||
}
|
||||
|
||||
public async deleteMeal(id: number): Promise<void> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
public async deleteMeal(id: number): Promise<void> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
await this.db.runAsync('DELETE FROM meals WHERE id = ?', [id]);
|
||||
}
|
||||
await this.db.runAsync('DELETE FROM meals WHERE id = ?', [id]);
|
||||
}
|
||||
|
||||
// CRUD Operations für Food Items
|
||||
public async createFoodItem(foodItem: Omit<FoodItem, 'id' | 'created_at'>): Promise<number> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
// CRUD Operations für Food Items
|
||||
public async createFoodItem(foodItem: Omit<FoodItem, 'id' | 'created_at'>): Promise<number> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
const result = await this.db.runAsync(
|
||||
`
|
||||
const result = await this.db.runAsync(
|
||||
`
|
||||
INSERT INTO food_items (
|
||||
meal_id, name, category, portion_size, calories, protein, carbs, fat,
|
||||
fiber, sugar, confidence, bounding_box, is_organic, is_processed, allergens
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
[
|
||||
foodItem.meal_id,
|
||||
foodItem.name,
|
||||
foodItem.category,
|
||||
foodItem.portion_size,
|
||||
foodItem.calories || null,
|
||||
foodItem.protein || null,
|
||||
foodItem.carbs || null,
|
||||
foodItem.fat || null,
|
||||
foodItem.fiber || null,
|
||||
foodItem.sugar || null,
|
||||
foodItem.confidence || null,
|
||||
foodItem.bounding_box || null,
|
||||
foodItem.is_organic,
|
||||
foodItem.is_processed,
|
||||
foodItem.allergens || null,
|
||||
]
|
||||
);
|
||||
[
|
||||
foodItem.meal_id,
|
||||
foodItem.name,
|
||||
foodItem.category,
|
||||
foodItem.portion_size,
|
||||
foodItem.calories || null,
|
||||
foodItem.protein || null,
|
||||
foodItem.carbs || null,
|
||||
foodItem.fat || null,
|
||||
foodItem.fiber || null,
|
||||
foodItem.sugar || null,
|
||||
foodItem.confidence || null,
|
||||
foodItem.bounding_box || null,
|
||||
foodItem.is_organic,
|
||||
foodItem.is_processed,
|
||||
foodItem.allergens || null,
|
||||
]
|
||||
);
|
||||
|
||||
return result.lastInsertRowId;
|
||||
}
|
||||
return result.lastInsertRowId;
|
||||
}
|
||||
|
||||
public async createFoodItemsBatch(foodItems: CreateFoodItemInput[]): Promise<number[]> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
if (foodItems.length === 0) return [];
|
||||
public async createFoodItemsBatch(foodItems: CreateFoodItemInput[]): Promise<number[]> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
if (foodItems.length === 0) return [];
|
||||
|
||||
const insertedIds: number[] = [];
|
||||
const insertedIds: number[] = [];
|
||||
|
||||
// Use a transaction for better performance
|
||||
await this.db.execAsync('BEGIN TRANSACTION');
|
||||
// Use a transaction for better performance
|
||||
await this.db.execAsync('BEGIN TRANSACTION');
|
||||
|
||||
try {
|
||||
for (const foodItem of foodItems) {
|
||||
const result = await this.db.runAsync(
|
||||
`INSERT INTO food_items (
|
||||
try {
|
||||
for (const foodItem of foodItems) {
|
||||
const result = await this.db.runAsync(
|
||||
`INSERT INTO food_items (
|
||||
meal_id, name, category, portion_size,
|
||||
calories, protein, carbs, fat, fiber, sugar,
|
||||
confidence, bounding_box, is_organic, is_processed, allergens
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
foodItem.meal_id,
|
||||
foodItem.name,
|
||||
foodItem.category || null,
|
||||
foodItem.portion_size || null,
|
||||
foodItem.calories || null,
|
||||
foodItem.protein || null,
|
||||
foodItem.carbs || null,
|
||||
foodItem.fat || null,
|
||||
foodItem.fiber || null,
|
||||
foodItem.sugar || null,
|
||||
foodItem.confidence || null,
|
||||
foodItem.bounding_box || null,
|
||||
foodItem.is_organic,
|
||||
foodItem.is_processed,
|
||||
foodItem.allergens || null,
|
||||
]
|
||||
);
|
||||
insertedIds.push(result.lastInsertRowId);
|
||||
}
|
||||
[
|
||||
foodItem.meal_id,
|
||||
foodItem.name,
|
||||
foodItem.category || null,
|
||||
foodItem.portion_size || null,
|
||||
foodItem.calories || null,
|
||||
foodItem.protein || null,
|
||||
foodItem.carbs || null,
|
||||
foodItem.fat || null,
|
||||
foodItem.fiber || null,
|
||||
foodItem.sugar || null,
|
||||
foodItem.confidence || null,
|
||||
foodItem.bounding_box || null,
|
||||
foodItem.is_organic,
|
||||
foodItem.is_processed,
|
||||
foodItem.allergens || null,
|
||||
]
|
||||
);
|
||||
insertedIds.push(result.lastInsertRowId);
|
||||
}
|
||||
|
||||
await this.db.execAsync('COMMIT');
|
||||
return insertedIds;
|
||||
} catch (error) {
|
||||
await this.db.execAsync('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
await this.db.execAsync('COMMIT');
|
||||
return insertedIds;
|
||||
} catch (error) {
|
||||
await this.db.execAsync('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async getFoodItemsByMealId(mealId: number): Promise<FoodItem[]> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
public async getFoodItemsByMealId(mealId: number): Promise<FoodItem[]> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
return await this.db.getAllAsync<FoodItem>(
|
||||
'SELECT * FROM food_items WHERE meal_id = ? ORDER BY created_at',
|
||||
[mealId]
|
||||
);
|
||||
}
|
||||
return await this.db.getAllAsync<FoodItem>(
|
||||
'SELECT * FROM food_items WHERE meal_id = ? ORDER BY created_at',
|
||||
[mealId]
|
||||
);
|
||||
}
|
||||
|
||||
// Statistiken und Aggregationen
|
||||
public async getMealStats(days: number = 7): Promise<{
|
||||
totalMeals: number;
|
||||
avgCalories: number;
|
||||
avgHealthScore: number;
|
||||
}> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
// Statistiken und Aggregationen
|
||||
public async getMealStats(days: number = 7): Promise<{
|
||||
totalMeals: number;
|
||||
avgCalories: number;
|
||||
avgHealthScore: number;
|
||||
}> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
const result = await this.db.getFirstAsync<{
|
||||
count: number;
|
||||
avg_calories: number;
|
||||
avg_health_score: number;
|
||||
}>(`
|
||||
const result = await this.db.getFirstAsync<{
|
||||
count: number;
|
||||
avg_calories: number;
|
||||
avg_health_score: number;
|
||||
}>(`
|
||||
SELECT
|
||||
COUNT(*) as count,
|
||||
AVG(total_calories) as avg_calories,
|
||||
|
|
@ -365,18 +365,18 @@ export class SQLiteService {
|
|||
AND analysis_status = 'completed'
|
||||
`);
|
||||
|
||||
return {
|
||||
totalMeals: result?.count || 0,
|
||||
avgCalories: Math.round(result?.avg_calories || 0),
|
||||
avgHealthScore: Math.round((result?.avg_health_score || 0) * 10) / 10,
|
||||
};
|
||||
}
|
||||
return {
|
||||
totalMeals: result?.count || 0,
|
||||
avgCalories: Math.round(result?.avg_calories || 0),
|
||||
avgHealthScore: Math.round((result?.avg_health_score || 0) * 10) / 10,
|
||||
};
|
||||
}
|
||||
|
||||
public async searchMeals(query: string): Promise<Meal[]> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
public async searchMeals(query: string): Promise<Meal[]> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
return await this.db.getAllAsync<Meal>(
|
||||
`
|
||||
return await this.db.getAllAsync<Meal>(
|
||||
`
|
||||
SELECT DISTINCT m.* FROM meals m
|
||||
LEFT JOIN food_items fi ON m.id = fi.meal_id
|
||||
WHERE m.user_notes LIKE ?
|
||||
|
|
@ -384,147 +384,146 @@ export class SQLiteService {
|
|||
OR fi.name LIKE ?
|
||||
ORDER BY m.timestamp DESC
|
||||
`,
|
||||
[`%${query}%`, `%${query}%`, `%${query}%`]
|
||||
);
|
||||
}
|
||||
[`%${query}%`, `%${query}%`, `%${query}%`]
|
||||
);
|
||||
}
|
||||
|
||||
// Hilfsmethoden
|
||||
public async close(): Promise<void> {
|
||||
if (this.db) {
|
||||
await this.db.closeAsync();
|
||||
this.db = null;
|
||||
}
|
||||
}
|
||||
// Hilfsmethoden
|
||||
public async close(): Promise<void> {
|
||||
if (this.db) {
|
||||
await this.db.closeAsync();
|
||||
this.db = null;
|
||||
}
|
||||
}
|
||||
|
||||
public async executeRaw(sql: string, params: any[] = []): Promise<any> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
return await this.db.runAsync(sql, params);
|
||||
}
|
||||
public async executeRaw(sql: string, params: any[] = []): Promise<any> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
return await this.db.runAsync(sql, params);
|
||||
}
|
||||
|
||||
// ==================== Sync Methods ====================
|
||||
// ==================== Sync Methods ====================
|
||||
|
||||
/**
|
||||
* Get all unsynced meals (sync_status = 'local' or 'pending')
|
||||
*/
|
||||
public async getUnsyncedMeals(): Promise<Meal[]> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
/**
|
||||
* Get all unsynced meals (sync_status = 'local' or 'pending')
|
||||
*/
|
||||
public async getUnsyncedMeals(): Promise<Meal[]> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
return await this.db.getAllAsync<Meal>(
|
||||
`SELECT * FROM meals WHERE sync_status IN ('local', 'pending') ORDER BY created_at DESC`
|
||||
);
|
||||
}
|
||||
return await this.db.getAllAsync<Meal>(
|
||||
`SELECT * FROM meals WHERE sync_status IN ('local', 'pending') ORDER BY created_at DESC`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get meal by cloud ID
|
||||
*/
|
||||
public async getMealByCloudId(cloudId: string): Promise<Meal | null> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
/**
|
||||
* Get meal by cloud ID
|
||||
*/
|
||||
public async getMealByCloudId(cloudId: string): Promise<Meal | null> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
const result = await this.db.getFirstAsync<Meal>(
|
||||
'SELECT * FROM meals WHERE cloud_id = ?',
|
||||
[cloudId]
|
||||
);
|
||||
const result = await this.db.getFirstAsync<Meal>('SELECT * FROM meals WHERE cloud_id = ?', [
|
||||
cloudId,
|
||||
]);
|
||||
|
||||
return result || null;
|
||||
}
|
||||
return result || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update cloud_id for a local meal
|
||||
*/
|
||||
public async updateCloudId(localId: number, cloudId: string): Promise<void> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
/**
|
||||
* Update cloud_id for a local meal
|
||||
*/
|
||||
public async updateCloudId(localId: number, cloudId: string): Promise<void> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
await this.db.runAsync(
|
||||
`UPDATE meals SET cloud_id = ?, updated_at = datetime('now') WHERE id = ?`,
|
||||
[cloudId, localId]
|
||||
);
|
||||
}
|
||||
await this.db.runAsync(
|
||||
`UPDATE meals SET cloud_id = ?, updated_at = datetime('now') WHERE id = ?`,
|
||||
[cloudId, localId]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a meal as synced
|
||||
*/
|
||||
public async markSynced(localId: number): Promise<void> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
/**
|
||||
* Mark a meal as synced
|
||||
*/
|
||||
public async markSynced(localId: number): Promise<void> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
await this.db.runAsync(
|
||||
`UPDATE meals SET sync_status = 'synced', last_sync_at = datetime('now'), updated_at = datetime('now') WHERE id = ?`,
|
||||
[localId]
|
||||
);
|
||||
}
|
||||
await this.db.runAsync(
|
||||
`UPDATE meals SET sync_status = 'synced', last_sync_at = datetime('now'), updated_at = datetime('now') WHERE id = ?`,
|
||||
[localId]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a meal by cloud ID
|
||||
*/
|
||||
public async deleteByCloudId(cloudId: string): Promise<void> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
/**
|
||||
* Delete a meal by cloud ID
|
||||
*/
|
||||
public async deleteByCloudId(cloudId: string): Promise<void> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
await this.db.runAsync('DELETE FROM meals WHERE cloud_id = ?', [cloudId]);
|
||||
}
|
||||
await this.db.runAsync('DELETE FROM meals WHERE cloud_id = ?', [cloudId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a meal from server data
|
||||
*/
|
||||
public async createMealFromServer(serverMeal: any): Promise<number> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
/**
|
||||
* Create a meal from server data
|
||||
*/
|
||||
public async createMealFromServer(serverMeal: any): Promise<number> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
const analysisResult = serverMeal.foodItems
|
||||
? JSON.stringify({
|
||||
foodName: serverMeal.foodName,
|
||||
foodItems: serverMeal.foodItems,
|
||||
})
|
||||
: null;
|
||||
const analysisResult = serverMeal.foodItems
|
||||
? JSON.stringify({
|
||||
foodName: serverMeal.foodName,
|
||||
foodItems: serverMeal.foodItems,
|
||||
})
|
||||
: null;
|
||||
|
||||
const result = await this.db.runAsync(
|
||||
`INSERT INTO meals (
|
||||
const result = await this.db.runAsync(
|
||||
`INSERT INTO meals (
|
||||
cloud_id, sync_status, version, last_sync_at,
|
||||
photo_path, photo_url, timestamp, created_at, updated_at,
|
||||
meal_type, analysis_result, analysis_status,
|
||||
total_calories, total_protein, total_carbs, total_fat, total_fiber, total_sugar,
|
||||
health_score, health_category, user_notes, user_rating
|
||||
) VALUES (?, ?, ?, datetime('now'), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
serverMeal.cloudId,
|
||||
'synced',
|
||||
1,
|
||||
serverMeal.imageUrl || '',
|
||||
serverMeal.imageUrl || null,
|
||||
serverMeal.createdAt,
|
||||
serverMeal.createdAt,
|
||||
serverMeal.updatedAt,
|
||||
serverMeal.mealType || null,
|
||||
analysisResult,
|
||||
serverMeal.analysisStatus || 'completed',
|
||||
serverMeal.calories || null,
|
||||
serverMeal.protein || null,
|
||||
serverMeal.carbohydrates || null,
|
||||
serverMeal.fat || null,
|
||||
serverMeal.fiber || null,
|
||||
serverMeal.sugar || null,
|
||||
serverMeal.healthScore || null,
|
||||
serverMeal.healthCategory || null,
|
||||
serverMeal.notes || null,
|
||||
serverMeal.userRating || null,
|
||||
]
|
||||
);
|
||||
[
|
||||
serverMeal.cloudId,
|
||||
'synced',
|
||||
1,
|
||||
serverMeal.imageUrl || '',
|
||||
serverMeal.imageUrl || null,
|
||||
serverMeal.createdAt,
|
||||
serverMeal.createdAt,
|
||||
serverMeal.updatedAt,
|
||||
serverMeal.mealType || null,
|
||||
analysisResult,
|
||||
serverMeal.analysisStatus || 'completed',
|
||||
serverMeal.calories || null,
|
||||
serverMeal.protein || null,
|
||||
serverMeal.carbohydrates || null,
|
||||
serverMeal.fat || null,
|
||||
serverMeal.fiber || null,
|
||||
serverMeal.sugar || null,
|
||||
serverMeal.healthScore || null,
|
||||
serverMeal.healthCategory || null,
|
||||
serverMeal.notes || null,
|
||||
serverMeal.userRating || null,
|
||||
]
|
||||
);
|
||||
|
||||
return result.lastInsertRowId;
|
||||
}
|
||||
return result.lastInsertRowId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a local meal from server data
|
||||
*/
|
||||
public async updateMealFromServer(localId: number, serverMeal: any): Promise<void> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
/**
|
||||
* Update a local meal from server data
|
||||
*/
|
||||
public async updateMealFromServer(localId: number, serverMeal: any): Promise<void> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
const analysisResult = serverMeal.foodItems
|
||||
? JSON.stringify({
|
||||
foodName: serverMeal.foodName,
|
||||
foodItems: serverMeal.foodItems,
|
||||
})
|
||||
: null;
|
||||
const analysisResult = serverMeal.foodItems
|
||||
? JSON.stringify({
|
||||
foodName: serverMeal.foodName,
|
||||
foodItems: serverMeal.foodItems,
|
||||
})
|
||||
: null;
|
||||
|
||||
await this.db.runAsync(
|
||||
`UPDATE meals SET
|
||||
await this.db.runAsync(
|
||||
`UPDATE meals SET
|
||||
sync_status = 'synced',
|
||||
last_sync_at = datetime('now'),
|
||||
photo_url = ?,
|
||||
|
|
@ -543,36 +542,36 @@ export class SQLiteService {
|
|||
user_rating = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?`,
|
||||
[
|
||||
serverMeal.imageUrl || null,
|
||||
serverMeal.mealType || null,
|
||||
analysisResult,
|
||||
serverMeal.analysisStatus || 'completed',
|
||||
serverMeal.calories || null,
|
||||
serverMeal.protein || null,
|
||||
serverMeal.carbohydrates || null,
|
||||
serverMeal.fat || null,
|
||||
serverMeal.fiber || null,
|
||||
serverMeal.sugar || null,
|
||||
serverMeal.healthScore || null,
|
||||
serverMeal.healthCategory || null,
|
||||
serverMeal.notes || null,
|
||||
serverMeal.userRating || null,
|
||||
serverMeal.updatedAt,
|
||||
localId,
|
||||
]
|
||||
);
|
||||
}
|
||||
[
|
||||
serverMeal.imageUrl || null,
|
||||
serverMeal.mealType || null,
|
||||
analysisResult,
|
||||
serverMeal.analysisStatus || 'completed',
|
||||
serverMeal.calories || null,
|
||||
serverMeal.protein || null,
|
||||
serverMeal.carbohydrates || null,
|
||||
serverMeal.fat || null,
|
||||
serverMeal.fiber || null,
|
||||
serverMeal.sugar || null,
|
||||
serverMeal.healthScore || null,
|
||||
serverMeal.healthCategory || null,
|
||||
serverMeal.notes || null,
|
||||
serverMeal.userRating || null,
|
||||
serverMeal.updatedAt,
|
||||
localId,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get meals modified since a given timestamp
|
||||
*/
|
||||
public async getMealsModifiedSince(since: string): Promise<Meal[]> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
/**
|
||||
* Get meals modified since a given timestamp
|
||||
*/
|
||||
public async getMealsModifiedSince(since: string): Promise<Meal[]> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
return await this.db.getAllAsync<Meal>(
|
||||
`SELECT * FROM meals WHERE updated_at > ? ORDER BY updated_at ASC`,
|
||||
[since]
|
||||
);
|
||||
}
|
||||
return await this.db.getAllAsync<Meal>(
|
||||
`SELECT * FROM meals WHERE updated_at > ? ORDER BY updated_at ASC`,
|
||||
[since]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,200 +2,200 @@ import * as FileSystem from 'expo-file-system';
|
|||
import { PhotoDimensions } from '../../types/Database';
|
||||
|
||||
export class PhotoService {
|
||||
private static instance: PhotoService;
|
||||
private photosDirectory: string;
|
||||
private static instance: PhotoService;
|
||||
private photosDirectory: string;
|
||||
|
||||
private constructor() {
|
||||
this.photosDirectory = `${FileSystem.documentDirectory}photos/`;
|
||||
}
|
||||
private constructor() {
|
||||
this.photosDirectory = `${FileSystem.documentDirectory}photos/`;
|
||||
}
|
||||
|
||||
public static getInstance(): PhotoService {
|
||||
if (!PhotoService.instance) {
|
||||
PhotoService.instance = new PhotoService();
|
||||
}
|
||||
return PhotoService.instance;
|
||||
}
|
||||
public static getInstance(): PhotoService {
|
||||
if (!PhotoService.instance) {
|
||||
PhotoService.instance = new PhotoService();
|
||||
}
|
||||
return PhotoService.instance;
|
||||
}
|
||||
|
||||
public async initialize(): Promise<void> {
|
||||
// Create photos directory if it doesn't exist
|
||||
const dirInfo = await FileSystem.getInfoAsync(this.photosDirectory);
|
||||
if (!dirInfo.exists) {
|
||||
await FileSystem.makeDirectoryAsync(this.photosDirectory, { intermediates: true });
|
||||
}
|
||||
}
|
||||
public async initialize(): Promise<void> {
|
||||
// Create photos directory if it doesn't exist
|
||||
const dirInfo = await FileSystem.getInfoAsync(this.photosDirectory);
|
||||
if (!dirInfo.exists) {
|
||||
await FileSystem.makeDirectoryAsync(this.photosDirectory, { intermediates: true });
|
||||
}
|
||||
}
|
||||
|
||||
public async savePhoto(
|
||||
uri: string,
|
||||
mealId?: number
|
||||
): Promise<{
|
||||
path: string;
|
||||
size: number;
|
||||
dimensions: PhotoDimensions;
|
||||
}> {
|
||||
await this.initialize();
|
||||
public async savePhoto(
|
||||
uri: string,
|
||||
mealId?: number
|
||||
): Promise<{
|
||||
path: string;
|
||||
size: number;
|
||||
dimensions: PhotoDimensions;
|
||||
}> {
|
||||
await this.initialize();
|
||||
|
||||
// Generate unique filename
|
||||
const timestamp = Date.now();
|
||||
const filename = mealId ? `meal_${mealId}_${timestamp}.jpg` : `temp_${timestamp}.jpg`;
|
||||
// Generate unique filename
|
||||
const timestamp = Date.now();
|
||||
const filename = mealId ? `meal_${mealId}_${timestamp}.jpg` : `temp_${timestamp}.jpg`;
|
||||
|
||||
const destPath = `${this.photosDirectory}${filename}`;
|
||||
const destPath = `${this.photosDirectory}${filename}`;
|
||||
|
||||
// Copy file to app directory
|
||||
await FileSystem.copyAsync({
|
||||
from: uri,
|
||||
to: destPath,
|
||||
});
|
||||
// Copy file to app directory
|
||||
await FileSystem.copyAsync({
|
||||
from: uri,
|
||||
to: destPath,
|
||||
});
|
||||
|
||||
// Get file info
|
||||
const fileInfo = await FileSystem.getInfoAsync(destPath);
|
||||
// Get file info
|
||||
const fileInfo = await FileSystem.getInfoAsync(destPath);
|
||||
|
||||
// Get image dimensions (basic implementation)
|
||||
const dimensions = await this.getImageDimensions(destPath);
|
||||
// Get image dimensions (basic implementation)
|
||||
const dimensions = await this.getImageDimensions(destPath);
|
||||
|
||||
return {
|
||||
path: destPath,
|
||||
size: fileInfo.size || 0,
|
||||
dimensions,
|
||||
};
|
||||
}
|
||||
return {
|
||||
path: destPath,
|
||||
size: fileInfo.size || 0,
|
||||
dimensions,
|
||||
};
|
||||
}
|
||||
|
||||
public async makePhotoPermanent(
|
||||
tempPath: string,
|
||||
mealId: number
|
||||
): Promise<{
|
||||
path: string;
|
||||
size: number;
|
||||
dimensions: PhotoDimensions;
|
||||
}> {
|
||||
await this.initialize();
|
||||
public async makePhotoPermanent(
|
||||
tempPath: string,
|
||||
mealId: number
|
||||
): Promise<{
|
||||
path: string;
|
||||
size: number;
|
||||
dimensions: PhotoDimensions;
|
||||
}> {
|
||||
await this.initialize();
|
||||
|
||||
// Generate permanent filename
|
||||
const timestamp = Date.now();
|
||||
const filename = `meal_${mealId}_${timestamp}.jpg`;
|
||||
const destPath = `${this.photosDirectory}${filename}`;
|
||||
// Generate permanent filename
|
||||
const timestamp = Date.now();
|
||||
const filename = `meal_${mealId}_${timestamp}.jpg`;
|
||||
const destPath = `${this.photosDirectory}${filename}`;
|
||||
|
||||
// Copy temp file to permanent location
|
||||
await FileSystem.copyAsync({
|
||||
from: tempPath,
|
||||
to: destPath,
|
||||
});
|
||||
// Copy temp file to permanent location
|
||||
await FileSystem.copyAsync({
|
||||
from: tempPath,
|
||||
to: destPath,
|
||||
});
|
||||
|
||||
// Get file info
|
||||
const fileInfo = await FileSystem.getInfoAsync(destPath);
|
||||
// Get file info
|
||||
const fileInfo = await FileSystem.getInfoAsync(destPath);
|
||||
|
||||
// Get image dimensions
|
||||
const dimensions = await this.getImageDimensions(destPath);
|
||||
// Get image dimensions
|
||||
const dimensions = await this.getImageDimensions(destPath);
|
||||
|
||||
// Delete the temporary file
|
||||
await this.deletePhoto(tempPath);
|
||||
// Delete the temporary file
|
||||
await this.deletePhoto(tempPath);
|
||||
|
||||
return {
|
||||
path: destPath,
|
||||
size: fileInfo.size || 0,
|
||||
dimensions,
|
||||
};
|
||||
}
|
||||
return {
|
||||
path: destPath,
|
||||
size: fileInfo.size || 0,
|
||||
dimensions,
|
||||
};
|
||||
}
|
||||
|
||||
public async deletePhoto(path: string): Promise<void> {
|
||||
try {
|
||||
const fileInfo = await FileSystem.getInfoAsync(path);
|
||||
if (fileInfo.exists) {
|
||||
await FileSystem.deleteAsync(path);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to delete photo:', error);
|
||||
}
|
||||
}
|
||||
public async deletePhoto(path: string): Promise<void> {
|
||||
try {
|
||||
const fileInfo = await FileSystem.getInfoAsync(path);
|
||||
if (fileInfo.exists) {
|
||||
await FileSystem.deleteAsync(path);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to delete photo:', error);
|
||||
}
|
||||
}
|
||||
|
||||
public async getPhotoAsBase64(path: string): Promise<string> {
|
||||
try {
|
||||
const base64 = await FileSystem.readAsStringAsync(path, {
|
||||
encoding: FileSystem.EncodingType.Base64,
|
||||
});
|
||||
return base64;
|
||||
} catch (error) {
|
||||
console.error('Failed to read photo as base64:', error);
|
||||
throw new Error('Failed to process image');
|
||||
}
|
||||
}
|
||||
public async getPhotoAsBase64(path: string): Promise<string> {
|
||||
try {
|
||||
const base64 = await FileSystem.readAsStringAsync(path, {
|
||||
encoding: FileSystem.EncodingType.Base64,
|
||||
});
|
||||
return base64;
|
||||
} catch (error) {
|
||||
console.error('Failed to read photo as base64:', error);
|
||||
throw new Error('Failed to process image');
|
||||
}
|
||||
}
|
||||
|
||||
private async getImageDimensions(path: string): Promise<PhotoDimensions> {
|
||||
// This is a simplified implementation
|
||||
// In a real app, you might use expo-image-manipulator or similar
|
||||
// to get actual image dimensions
|
||||
return {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
};
|
||||
}
|
||||
private async getImageDimensions(path: string): Promise<PhotoDimensions> {
|
||||
// This is a simplified implementation
|
||||
// In a real app, you might use expo-image-manipulator or similar
|
||||
// to get actual image dimensions
|
||||
return {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
};
|
||||
}
|
||||
|
||||
public async cleanupTempPhotos(): Promise<void> {
|
||||
try {
|
||||
await this.initialize();
|
||||
public async cleanupTempPhotos(): Promise<void> {
|
||||
try {
|
||||
await this.initialize();
|
||||
|
||||
// Check if directory exists before trying to read it
|
||||
const dirInfo = await FileSystem.getInfoAsync(this.photosDirectory);
|
||||
if (!dirInfo.exists) {
|
||||
console.log('Photos directory does not exist yet, skipping cleanup');
|
||||
return;
|
||||
}
|
||||
// Check if directory exists before trying to read it
|
||||
const dirInfo = await FileSystem.getInfoAsync(this.photosDirectory);
|
||||
if (!dirInfo.exists) {
|
||||
console.log('Photos directory does not exist yet, skipping cleanup');
|
||||
return;
|
||||
}
|
||||
|
||||
const files = await FileSystem.readDirectoryAsync(this.photosDirectory);
|
||||
const tempFiles = files.filter((file) => file.startsWith('temp_'));
|
||||
const files = await FileSystem.readDirectoryAsync(this.photosDirectory);
|
||||
const tempFiles = files.filter((file) => file.startsWith('temp_'));
|
||||
|
||||
// Delete temp files older than 1 hour
|
||||
const oneHourAgo = Date.now() - 60 * 60 * 1000;
|
||||
// Delete temp files older than 1 hour
|
||||
const oneHourAgo = Date.now() - 60 * 60 * 1000;
|
||||
|
||||
for (const file of tempFiles) {
|
||||
const filePath = `${this.photosDirectory}${file}`;
|
||||
const fileInfo = await FileSystem.getInfoAsync(filePath);
|
||||
for (const file of tempFiles) {
|
||||
const filePath = `${this.photosDirectory}${file}`;
|
||||
const fileInfo = await FileSystem.getInfoAsync(filePath);
|
||||
|
||||
if (
|
||||
fileInfo.exists &&
|
||||
fileInfo.modificationTime &&
|
||||
fileInfo.modificationTime < oneHourAgo
|
||||
) {
|
||||
await FileSystem.deleteAsync(filePath);
|
||||
}
|
||||
}
|
||||
if (
|
||||
fileInfo.exists &&
|
||||
fileInfo.modificationTime &&
|
||||
fileInfo.modificationTime < oneHourAgo
|
||||
) {
|
||||
await FileSystem.deleteAsync(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
if (tempFiles.length > 0) {
|
||||
console.log(`Cleaned up ${tempFiles.length} temporary photos`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to cleanup temp photos:', error);
|
||||
}
|
||||
}
|
||||
if (tempFiles.length > 0) {
|
||||
console.log(`Cleaned up ${tempFiles.length} temporary photos`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to cleanup temp photos:', error);
|
||||
}
|
||||
}
|
||||
|
||||
public async getStorageStats(): Promise<{
|
||||
totalPhotos: number;
|
||||
totalSize: number;
|
||||
averageSize: number;
|
||||
}> {
|
||||
try {
|
||||
await this.initialize();
|
||||
const files = await FileSystem.readDirectoryAsync(this.photosDirectory);
|
||||
const photoFiles = files.filter((file) => file.endsWith('.jpg') || file.endsWith('.png'));
|
||||
public async getStorageStats(): Promise<{
|
||||
totalPhotos: number;
|
||||
totalSize: number;
|
||||
averageSize: number;
|
||||
}> {
|
||||
try {
|
||||
await this.initialize();
|
||||
const files = await FileSystem.readDirectoryAsync(this.photosDirectory);
|
||||
const photoFiles = files.filter((file) => file.endsWith('.jpg') || file.endsWith('.png'));
|
||||
|
||||
let totalSize = 0;
|
||||
for (const file of photoFiles) {
|
||||
const filePath = `${this.photosDirectory}${file}`;
|
||||
const fileInfo = await FileSystem.getInfoAsync(filePath);
|
||||
totalSize += fileInfo.size || 0;
|
||||
}
|
||||
let totalSize = 0;
|
||||
for (const file of photoFiles) {
|
||||
const filePath = `${this.photosDirectory}${file}`;
|
||||
const fileInfo = await FileSystem.getInfoAsync(filePath);
|
||||
totalSize += fileInfo.size || 0;
|
||||
}
|
||||
|
||||
return {
|
||||
totalPhotos: photoFiles.length,
|
||||
totalSize,
|
||||
averageSize: photoFiles.length > 0 ? totalSize / photoFiles.length : 0,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to get storage stats:', error);
|
||||
return {
|
||||
totalPhotos: 0,
|
||||
totalSize: 0,
|
||||
averageSize: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
totalPhotos: photoFiles.length,
|
||||
totalSize,
|
||||
averageSize: photoFiles.length > 0 ? totalSize / photoFiles.length : 0,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to get storage stats:', error);
|
||||
return {
|
||||
totalPhotos: 0,
|
||||
totalSize: 0,
|
||||
averageSize: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,343 +5,341 @@ import type { Meal } from '../../types/Database';
|
|||
const BACKEND_URL = process.env.EXPO_PUBLIC_BACKEND_URL || 'http://localhost:3002';
|
||||
|
||||
export interface SyncResult {
|
||||
success: boolean;
|
||||
created: number;
|
||||
updated: number;
|
||||
deleted: number;
|
||||
conflicts: ConflictInfo[];
|
||||
error?: string;
|
||||
success: boolean;
|
||||
created: number;
|
||||
updated: number;
|
||||
deleted: number;
|
||||
conflicts: ConflictInfo[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ConflictInfo {
|
||||
cloudId: string;
|
||||
localVersion: number;
|
||||
serverVersion: number;
|
||||
serverData: any;
|
||||
message: string;
|
||||
cloudId: string;
|
||||
localVersion: number;
|
||||
serverVersion: number;
|
||||
serverData: any;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface LocalMealForSync {
|
||||
localId: number;
|
||||
cloudId?: string;
|
||||
foodName: string;
|
||||
imageUrl?: string;
|
||||
calories?: number;
|
||||
protein?: number;
|
||||
carbohydrates?: number;
|
||||
fat?: number;
|
||||
fiber?: number;
|
||||
sugar?: number;
|
||||
sodium?: number;
|
||||
servingSize?: string;
|
||||
mealType?: string;
|
||||
analysisStatus?: string;
|
||||
healthScore?: number;
|
||||
healthCategory?: string;
|
||||
notes?: string;
|
||||
userRating?: number;
|
||||
foodItems?: any[];
|
||||
version: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
localId: number;
|
||||
cloudId?: string;
|
||||
foodName: string;
|
||||
imageUrl?: string;
|
||||
calories?: number;
|
||||
protein?: number;
|
||||
carbohydrates?: number;
|
||||
fat?: number;
|
||||
fiber?: number;
|
||||
sugar?: number;
|
||||
sodium?: number;
|
||||
servingSize?: string;
|
||||
mealType?: string;
|
||||
analysisStatus?: string;
|
||||
healthScore?: number;
|
||||
healthCategory?: string;
|
||||
notes?: string;
|
||||
userRating?: number;
|
||||
foodItems?: any[];
|
||||
version: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync Service for synchronizing local SQLite data with the backend
|
||||
*/
|
||||
export class SyncService {
|
||||
private static instance: SyncService;
|
||||
private isSyncing = false;
|
||||
private lastSyncAt: string | null = null;
|
||||
private static instance: SyncService;
|
||||
private isSyncing = false;
|
||||
private lastSyncAt: string | null = null;
|
||||
|
||||
private constructor() {}
|
||||
private constructor() {}
|
||||
|
||||
public static getInstance(): SyncService {
|
||||
if (!SyncService.instance) {
|
||||
SyncService.instance = new SyncService();
|
||||
}
|
||||
return SyncService.instance;
|
||||
}
|
||||
public static getInstance(): SyncService {
|
||||
if (!SyncService.instance) {
|
||||
SyncService.instance = new SyncService();
|
||||
}
|
||||
return SyncService.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if sync is currently in progress
|
||||
*/
|
||||
public isSyncInProgress(): boolean {
|
||||
return this.isSyncing;
|
||||
}
|
||||
/**
|
||||
* Check if sync is currently in progress
|
||||
*/
|
||||
public isSyncInProgress(): boolean {
|
||||
return this.isSyncing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get last sync timestamp
|
||||
*/
|
||||
public getLastSyncAt(): string | null {
|
||||
return this.lastSyncAt;
|
||||
}
|
||||
/**
|
||||
* Get last sync timestamp
|
||||
*/
|
||||
public getLastSyncAt(): string | null {
|
||||
return this.lastSyncAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a full sync (push + pull)
|
||||
*/
|
||||
public async fullSync(): Promise<SyncResult> {
|
||||
if (this.isSyncing) {
|
||||
return {
|
||||
success: false,
|
||||
created: 0,
|
||||
updated: 0,
|
||||
deleted: 0,
|
||||
conflicts: [],
|
||||
error: 'Sync already in progress',
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Perform a full sync (push + pull)
|
||||
*/
|
||||
public async fullSync(): Promise<SyncResult> {
|
||||
if (this.isSyncing) {
|
||||
return {
|
||||
success: false,
|
||||
created: 0,
|
||||
updated: 0,
|
||||
deleted: 0,
|
||||
conflicts: [],
|
||||
error: 'Sync already in progress',
|
||||
};
|
||||
}
|
||||
|
||||
this.isSyncing = true;
|
||||
this.isSyncing = true;
|
||||
|
||||
try {
|
||||
// First push local changes
|
||||
const pushResult = await this.pushChanges();
|
||||
if (!pushResult.success) {
|
||||
return pushResult;
|
||||
}
|
||||
try {
|
||||
// First push local changes
|
||||
const pushResult = await this.pushChanges();
|
||||
if (!pushResult.success) {
|
||||
return pushResult;
|
||||
}
|
||||
|
||||
// Then pull server changes
|
||||
const pullResult = await this.pullChanges();
|
||||
// Then pull server changes
|
||||
const pullResult = await this.pullChanges();
|
||||
|
||||
return {
|
||||
success: pullResult.success,
|
||||
created: pushResult.created + pullResult.created,
|
||||
updated: pushResult.updated + pullResult.updated,
|
||||
deleted: pullResult.deleted,
|
||||
conflicts: pushResult.conflicts,
|
||||
error: pullResult.error,
|
||||
};
|
||||
} finally {
|
||||
this.isSyncing = false;
|
||||
}
|
||||
}
|
||||
return {
|
||||
success: pullResult.success,
|
||||
created: pushResult.created + pullResult.created,
|
||||
updated: pushResult.updated + pullResult.updated,
|
||||
deleted: pullResult.deleted,
|
||||
conflicts: pushResult.conflicts,
|
||||
error: pullResult.error,
|
||||
};
|
||||
} finally {
|
||||
this.isSyncing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Push local changes to server
|
||||
*/
|
||||
public async pushChanges(): Promise<SyncResult> {
|
||||
try {
|
||||
const authHeader = await tokenManager.getAuthHeader();
|
||||
if (!authHeader.Authorization) {
|
||||
return {
|
||||
success: false,
|
||||
created: 0,
|
||||
updated: 0,
|
||||
deleted: 0,
|
||||
conflicts: [],
|
||||
error: 'Not authenticated',
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Push local changes to server
|
||||
*/
|
||||
public async pushChanges(): Promise<SyncResult> {
|
||||
try {
|
||||
const authHeader = await tokenManager.getAuthHeader();
|
||||
if (!authHeader.Authorization) {
|
||||
return {
|
||||
success: false,
|
||||
created: 0,
|
||||
updated: 0,
|
||||
deleted: 0,
|
||||
conflicts: [],
|
||||
error: 'Not authenticated',
|
||||
};
|
||||
}
|
||||
|
||||
const db = SQLiteService.getInstance();
|
||||
const db = SQLiteService.getInstance();
|
||||
|
||||
// Get unsynced meals
|
||||
const unsyncedMeals = await db.getUnsyncedMeals();
|
||||
// Get unsynced meals
|
||||
const unsyncedMeals = await db.getUnsyncedMeals();
|
||||
|
||||
if (unsyncedMeals.length === 0) {
|
||||
return {
|
||||
success: true,
|
||||
created: 0,
|
||||
updated: 0,
|
||||
deleted: 0,
|
||||
conflicts: [],
|
||||
};
|
||||
}
|
||||
if (unsyncedMeals.length === 0) {
|
||||
return {
|
||||
success: true,
|
||||
created: 0,
|
||||
updated: 0,
|
||||
deleted: 0,
|
||||
conflicts: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Map to sync format
|
||||
const mealsForSync: LocalMealForSync[] = unsyncedMeals.map((meal) =>
|
||||
this.mapMealToSyncFormat(meal)
|
||||
);
|
||||
// Map to sync format
|
||||
const mealsForSync: LocalMealForSync[] = unsyncedMeals.map((meal) =>
|
||||
this.mapMealToSyncFormat(meal)
|
||||
);
|
||||
|
||||
// Get deleted meals (meals marked for deletion)
|
||||
const deletedIds: string[] = []; // TODO: Implement delete tracking
|
||||
// Get deleted meals (meals marked for deletion)
|
||||
const deletedIds: string[] = []; // TODO: Implement delete tracking
|
||||
|
||||
const response = await fetch(`${BACKEND_URL}/api/sync/push`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...authHeader,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
meals: mealsForSync,
|
||||
deletedIds,
|
||||
lastSyncAt: this.lastSyncAt,
|
||||
}),
|
||||
});
|
||||
const response = await fetch(`${BACKEND_URL}/api/sync/push`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...authHeader,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
meals: mealsForSync,
|
||||
deletedIds,
|
||||
lastSyncAt: this.lastSyncAt,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({}));
|
||||
return {
|
||||
success: false,
|
||||
created: 0,
|
||||
updated: 0,
|
||||
deleted: 0,
|
||||
conflicts: [],
|
||||
error: error.message || 'Push failed',
|
||||
};
|
||||
}
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({}));
|
||||
return {
|
||||
success: false,
|
||||
created: 0,
|
||||
updated: 0,
|
||||
deleted: 0,
|
||||
conflicts: [],
|
||||
error: error.message || 'Push failed',
|
||||
};
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
const result = await response.json();
|
||||
|
||||
// Update local records with cloud IDs
|
||||
for (const created of result.created) {
|
||||
await db.updateCloudId(created.localId, created.cloudId);
|
||||
await db.markSynced(created.localId);
|
||||
}
|
||||
// Update local records with cloud IDs
|
||||
for (const created of result.created) {
|
||||
await db.updateCloudId(created.localId, created.cloudId);
|
||||
await db.markSynced(created.localId);
|
||||
}
|
||||
|
||||
// Mark updated records as synced
|
||||
for (const cloudId of result.updated) {
|
||||
const meal = unsyncedMeals.find((m) => m.cloud_id === cloudId);
|
||||
if (meal && meal.id) {
|
||||
await db.markSynced(meal.id);
|
||||
}
|
||||
}
|
||||
// Mark updated records as synced
|
||||
for (const cloudId of result.updated) {
|
||||
const meal = unsyncedMeals.find((m) => m.cloud_id === cloudId);
|
||||
if (meal && meal.id) {
|
||||
await db.markSynced(meal.id);
|
||||
}
|
||||
}
|
||||
|
||||
this.lastSyncAt = result.serverTime;
|
||||
this.lastSyncAt = result.serverTime;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
created: result.created.length,
|
||||
updated: result.updated.length,
|
||||
deleted: 0,
|
||||
conflicts: result.conflicts || [],
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Push sync error:', error);
|
||||
return {
|
||||
success: false,
|
||||
created: 0,
|
||||
updated: 0,
|
||||
deleted: 0,
|
||||
conflicts: [],
|
||||
error: error instanceof Error ? error.message : 'Push failed',
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
created: result.created.length,
|
||||
updated: result.updated.length,
|
||||
deleted: 0,
|
||||
conflicts: result.conflicts || [],
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Push sync error:', error);
|
||||
return {
|
||||
success: false,
|
||||
created: 0,
|
||||
updated: 0,
|
||||
deleted: 0,
|
||||
conflicts: [],
|
||||
error: error instanceof Error ? error.message : 'Push failed',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull changes from server
|
||||
*/
|
||||
public async pullChanges(): Promise<SyncResult> {
|
||||
try {
|
||||
const authHeader = await tokenManager.getAuthHeader();
|
||||
if (!authHeader.Authorization) {
|
||||
return {
|
||||
success: false,
|
||||
created: 0,
|
||||
updated: 0,
|
||||
deleted: 0,
|
||||
conflicts: [],
|
||||
error: 'Not authenticated',
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Pull changes from server
|
||||
*/
|
||||
public async pullChanges(): Promise<SyncResult> {
|
||||
try {
|
||||
const authHeader = await tokenManager.getAuthHeader();
|
||||
if (!authHeader.Authorization) {
|
||||
return {
|
||||
success: false,
|
||||
created: 0,
|
||||
updated: 0,
|
||||
deleted: 0,
|
||||
conflicts: [],
|
||||
error: 'Not authenticated',
|
||||
};
|
||||
}
|
||||
|
||||
const url = new URL(`${BACKEND_URL}/api/sync/pull`);
|
||||
if (this.lastSyncAt) {
|
||||
url.searchParams.set('since', this.lastSyncAt);
|
||||
}
|
||||
const url = new URL(`${BACKEND_URL}/api/sync/pull`);
|
||||
if (this.lastSyncAt) {
|
||||
url.searchParams.set('since', this.lastSyncAt);
|
||||
}
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...authHeader,
|
||||
},
|
||||
});
|
||||
const response = await fetch(url.toString(), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...authHeader,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({}));
|
||||
return {
|
||||
success: false,
|
||||
created: 0,
|
||||
updated: 0,
|
||||
deleted: 0,
|
||||
conflicts: [],
|
||||
error: error.message || 'Pull failed',
|
||||
};
|
||||
}
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({}));
|
||||
return {
|
||||
success: false,
|
||||
created: 0,
|
||||
updated: 0,
|
||||
deleted: 0,
|
||||
conflicts: [],
|
||||
error: error.message || 'Pull failed',
|
||||
};
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
const db = SQLiteService.getInstance();
|
||||
const result = await response.json();
|
||||
const db = SQLiteService.getInstance();
|
||||
|
||||
let created = 0;
|
||||
let updated = 0;
|
||||
let deleted = 0;
|
||||
let created = 0;
|
||||
let updated = 0;
|
||||
let deleted = 0;
|
||||
|
||||
// Process server meals
|
||||
for (const serverMeal of result.meals) {
|
||||
const existingMeal = await db.getMealByCloudId(serverMeal.cloudId);
|
||||
// Process server meals
|
||||
for (const serverMeal of result.meals) {
|
||||
const existingMeal = await db.getMealByCloudId(serverMeal.cloudId);
|
||||
|
||||
if (existingMeal) {
|
||||
// Update existing local meal
|
||||
await db.updateMealFromServer(existingMeal.id!, serverMeal);
|
||||
updated++;
|
||||
} else {
|
||||
// Create new local meal
|
||||
await db.createMealFromServer(serverMeal);
|
||||
created++;
|
||||
}
|
||||
}
|
||||
if (existingMeal) {
|
||||
// Update existing local meal
|
||||
await db.updateMealFromServer(existingMeal.id!, serverMeal);
|
||||
updated++;
|
||||
} else {
|
||||
// Create new local meal
|
||||
await db.createMealFromServer(serverMeal);
|
||||
created++;
|
||||
}
|
||||
}
|
||||
|
||||
// Process deletions
|
||||
for (const cloudId of result.deletedIds) {
|
||||
await db.deleteByCloudId(cloudId);
|
||||
deleted++;
|
||||
}
|
||||
// Process deletions
|
||||
for (const cloudId of result.deletedIds) {
|
||||
await db.deleteByCloudId(cloudId);
|
||||
deleted++;
|
||||
}
|
||||
|
||||
this.lastSyncAt = result.serverTime;
|
||||
this.lastSyncAt = result.serverTime;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
created,
|
||||
updated,
|
||||
deleted,
|
||||
conflicts: [],
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Pull sync error:', error);
|
||||
return {
|
||||
success: false,
|
||||
created: 0,
|
||||
updated: 0,
|
||||
deleted: 0,
|
||||
conflicts: [],
|
||||
error: error instanceof Error ? error.message : 'Pull failed',
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
created,
|
||||
updated,
|
||||
deleted,
|
||||
conflicts: [],
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Pull sync error:', error);
|
||||
return {
|
||||
success: false,
|
||||
created: 0,
|
||||
updated: 0,
|
||||
deleted: 0,
|
||||
conflicts: [],
|
||||
error: error instanceof Error ? error.message : 'Pull failed',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map local meal to sync format
|
||||
*/
|
||||
private mapMealToSyncFormat(meal: Meal): LocalMealForSync {
|
||||
return {
|
||||
localId: meal.id!,
|
||||
cloudId: meal.cloud_id || undefined,
|
||||
foodName: meal.analysis_result
|
||||
? JSON.parse(meal.analysis_result).foodName || 'Unbekanntes Gericht'
|
||||
: 'Unbekanntes Gericht',
|
||||
imageUrl: meal.photo_url || undefined,
|
||||
calories: meal.total_calories || undefined,
|
||||
protein: meal.total_protein || undefined,
|
||||
carbohydrates: meal.total_carbs || undefined,
|
||||
fat: meal.total_fat || undefined,
|
||||
fiber: meal.total_fiber || undefined,
|
||||
sugar: meal.total_sugar || undefined,
|
||||
servingSize: undefined,
|
||||
mealType: meal.meal_type || undefined,
|
||||
analysisStatus: meal.analysis_status || 'completed',
|
||||
healthScore: meal.health_score || undefined,
|
||||
healthCategory: meal.health_category || undefined,
|
||||
notes: meal.user_notes || undefined,
|
||||
userRating: meal.user_rating || undefined,
|
||||
foodItems: meal.analysis_result
|
||||
? JSON.parse(meal.analysis_result).foodItems
|
||||
: [],
|
||||
version: meal.version || 1,
|
||||
createdAt: meal.created_at || new Date().toISOString(),
|
||||
updatedAt: meal.updated_at || new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Map local meal to sync format
|
||||
*/
|
||||
private mapMealToSyncFormat(meal: Meal): LocalMealForSync {
|
||||
return {
|
||||
localId: meal.id!,
|
||||
cloudId: meal.cloud_id || undefined,
|
||||
foodName: meal.analysis_result
|
||||
? JSON.parse(meal.analysis_result).foodName || 'Unbekanntes Gericht'
|
||||
: 'Unbekanntes Gericht',
|
||||
imageUrl: meal.photo_url || undefined,
|
||||
calories: meal.total_calories || undefined,
|
||||
protein: meal.total_protein || undefined,
|
||||
carbohydrates: meal.total_carbs || undefined,
|
||||
fat: meal.total_fat || undefined,
|
||||
fiber: meal.total_fiber || undefined,
|
||||
sugar: meal.total_sugar || undefined,
|
||||
servingSize: undefined,
|
||||
mealType: meal.meal_type || undefined,
|
||||
analysisStatus: meal.analysis_status || 'completed',
|
||||
healthScore: meal.health_score || undefined,
|
||||
healthCategory: meal.health_category || undefined,
|
||||
notes: meal.user_notes || undefined,
|
||||
userRating: meal.user_rating || undefined,
|
||||
foodItems: meal.analysis_result ? JSON.parse(meal.analysis_result).foodItems : [],
|
||||
version: meal.version || 1,
|
||||
createdAt: meal.created_at || new Date().toISOString(),
|
||||
updatedAt: meal.updated_at || new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,118 +1,118 @@
|
|||
import { create } from 'zustand';
|
||||
|
||||
interface AppState {
|
||||
isInitialized: boolean;
|
||||
isOnline: boolean;
|
||||
currentScreen: 'home' | 'camera' | 'detail' | 'settings';
|
||||
isInitialized: boolean;
|
||||
isOnline: boolean;
|
||||
currentScreen: 'home' | 'camera' | 'detail' | 'settings';
|
||||
|
||||
// UI States
|
||||
showCameraModal: boolean;
|
||||
cameraMode: 'camera' | 'gallery' | null;
|
||||
isPhotoProcessing: boolean;
|
||||
// UI States
|
||||
showCameraModal: boolean;
|
||||
cameraMode: 'camera' | 'gallery' | null;
|
||||
isPhotoProcessing: boolean;
|
||||
|
||||
// User Preferences
|
||||
defaultMealType: 'breakfast' | 'lunch' | 'dinner' | 'snack' | null;
|
||||
enableNotifications: boolean;
|
||||
preferredUnits: 'metric' | 'imperial';
|
||||
theme: 'light' | 'dark' | 'system';
|
||||
// User Preferences
|
||||
defaultMealType: 'breakfast' | 'lunch' | 'dinner' | 'snack' | null;
|
||||
enableNotifications: boolean;
|
||||
preferredUnits: 'metric' | 'imperial';
|
||||
theme: 'light' | 'dark' | 'system';
|
||||
|
||||
// Stats Cache
|
||||
statsCache: {
|
||||
totalMeals: number;
|
||||
avgCalories: number;
|
||||
avgHealthScore: number;
|
||||
lastUpdated: string | null;
|
||||
};
|
||||
// Stats Cache
|
||||
statsCache: {
|
||||
totalMeals: number;
|
||||
avgCalories: number;
|
||||
avgHealthScore: number;
|
||||
lastUpdated: string | null;
|
||||
};
|
||||
|
||||
// Actions
|
||||
setInitialized: (initialized: boolean) => void;
|
||||
setOnlineStatus: (online: boolean) => void;
|
||||
setCurrentScreen: (screen: AppState['currentScreen']) => void;
|
||||
toggleCameraModal: (show?: boolean, mode?: 'camera' | 'gallery') => void;
|
||||
setPhotoProcessing: (processing: boolean) => void;
|
||||
updateUserPreferences: (
|
||||
prefs: Partial<
|
||||
Pick<AppState, 'defaultMealType' | 'enableNotifications' | 'preferredUnits' | 'theme'>
|
||||
>
|
||||
) => void;
|
||||
setTheme: (theme: 'light' | 'dark' | 'system') => void;
|
||||
updateStatsCache: (stats: Omit<AppState['statsCache'], 'lastUpdated'>) => void;
|
||||
resetStats: () => void;
|
||||
resetToDefaults: () => void;
|
||||
// Actions
|
||||
setInitialized: (initialized: boolean) => void;
|
||||
setOnlineStatus: (online: boolean) => void;
|
||||
setCurrentScreen: (screen: AppState['currentScreen']) => void;
|
||||
toggleCameraModal: (show?: boolean, mode?: 'camera' | 'gallery') => void;
|
||||
setPhotoProcessing: (processing: boolean) => void;
|
||||
updateUserPreferences: (
|
||||
prefs: Partial<
|
||||
Pick<AppState, 'defaultMealType' | 'enableNotifications' | 'preferredUnits' | 'theme'>
|
||||
>
|
||||
) => void;
|
||||
setTheme: (theme: 'light' | 'dark' | 'system') => void;
|
||||
updateStatsCache: (stats: Omit<AppState['statsCache'], 'lastUpdated'>) => void;
|
||||
resetStats: () => void;
|
||||
resetToDefaults: () => void;
|
||||
}
|
||||
|
||||
export const useAppStore = create<AppState>((set, get) => ({
|
||||
isInitialized: false,
|
||||
isOnline: true,
|
||||
currentScreen: 'home',
|
||||
showCameraModal: false,
|
||||
cameraMode: null,
|
||||
isPhotoProcessing: false,
|
||||
defaultMealType: null,
|
||||
enableNotifications: true,
|
||||
preferredUnits: 'metric',
|
||||
theme: 'system',
|
||||
statsCache: {
|
||||
totalMeals: 0,
|
||||
avgCalories: 0,
|
||||
avgHealthScore: 0,
|
||||
lastUpdated: null,
|
||||
},
|
||||
isInitialized: false,
|
||||
isOnline: true,
|
||||
currentScreen: 'home',
|
||||
showCameraModal: false,
|
||||
cameraMode: null,
|
||||
isPhotoProcessing: false,
|
||||
defaultMealType: null,
|
||||
enableNotifications: true,
|
||||
preferredUnits: 'metric',
|
||||
theme: 'system',
|
||||
statsCache: {
|
||||
totalMeals: 0,
|
||||
avgCalories: 0,
|
||||
avgHealthScore: 0,
|
||||
lastUpdated: null,
|
||||
},
|
||||
|
||||
setInitialized: (initialized: boolean) => set({ isInitialized: initialized }),
|
||||
setInitialized: (initialized: boolean) => set({ isInitialized: initialized }),
|
||||
|
||||
setOnlineStatus: (online: boolean) => set({ isOnline: online }),
|
||||
setOnlineStatus: (online: boolean) => set({ isOnline: online }),
|
||||
|
||||
setCurrentScreen: (screen: AppState['currentScreen']) => set({ currentScreen: screen }),
|
||||
setCurrentScreen: (screen: AppState['currentScreen']) => set({ currentScreen: screen }),
|
||||
|
||||
toggleCameraModal: (show?: boolean, mode?: 'camera' | 'gallery') => {
|
||||
const currentShow = get().showCameraModal;
|
||||
const newShow = show !== undefined ? show : !currentShow;
|
||||
set({
|
||||
showCameraModal: newShow,
|
||||
cameraMode: newShow ? mode || 'camera' : null,
|
||||
});
|
||||
},
|
||||
toggleCameraModal: (show?: boolean, mode?: 'camera' | 'gallery') => {
|
||||
const currentShow = get().showCameraModal;
|
||||
const newShow = show !== undefined ? show : !currentShow;
|
||||
set({
|
||||
showCameraModal: newShow,
|
||||
cameraMode: newShow ? mode || 'camera' : null,
|
||||
});
|
||||
},
|
||||
|
||||
setPhotoProcessing: (processing: boolean) => set({ isPhotoProcessing: processing }),
|
||||
setPhotoProcessing: (processing: boolean) => set({ isPhotoProcessing: processing }),
|
||||
|
||||
updateUserPreferences: (prefs) => set(prefs),
|
||||
updateUserPreferences: (prefs) => set(prefs),
|
||||
|
||||
setTheme: (theme: 'light' | 'dark' | 'system') => set({ theme }),
|
||||
setTheme: (theme: 'light' | 'dark' | 'system') => set({ theme }),
|
||||
|
||||
updateStatsCache: (stats) =>
|
||||
set({
|
||||
statsCache: {
|
||||
...stats,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
},
|
||||
}),
|
||||
updateStatsCache: (stats) =>
|
||||
set({
|
||||
statsCache: {
|
||||
...stats,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
},
|
||||
}),
|
||||
|
||||
resetStats: () =>
|
||||
set({
|
||||
statsCache: {
|
||||
totalMeals: 0,
|
||||
avgCalories: 0,
|
||||
avgHealthScore: 0,
|
||||
lastUpdated: null,
|
||||
},
|
||||
}),
|
||||
resetStats: () =>
|
||||
set({
|
||||
statsCache: {
|
||||
totalMeals: 0,
|
||||
avgCalories: 0,
|
||||
avgHealthScore: 0,
|
||||
lastUpdated: null,
|
||||
},
|
||||
}),
|
||||
|
||||
resetToDefaults: () =>
|
||||
set({
|
||||
isInitialized: false,
|
||||
currentScreen: 'home',
|
||||
showCameraModal: false,
|
||||
cameraMode: null,
|
||||
isPhotoProcessing: false,
|
||||
defaultMealType: null,
|
||||
enableNotifications: true,
|
||||
preferredUnits: 'metric',
|
||||
statsCache: {
|
||||
totalMeals: 0,
|
||||
avgCalories: 0,
|
||||
avgHealthScore: 0,
|
||||
lastUpdated: null,
|
||||
},
|
||||
}),
|
||||
resetToDefaults: () =>
|
||||
set({
|
||||
isInitialized: false,
|
||||
currentScreen: 'home',
|
||||
showCameraModal: false,
|
||||
cameraMode: null,
|
||||
isPhotoProcessing: false,
|
||||
defaultMealType: null,
|
||||
enableNotifications: true,
|
||||
preferredUnits: 'metric',
|
||||
statsCache: {
|
||||
totalMeals: 0,
|
||||
avgCalories: 0,
|
||||
avgHealthScore: 0,
|
||||
lastUpdated: null,
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -3,298 +3,307 @@ import { authService, type UserData, type AuthResult } from '../services/auth/au
|
|||
import { tokenManager } from '../services/auth/tokenManager';
|
||||
|
||||
interface AuthState {
|
||||
// State
|
||||
user: UserData | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
isInitialized: boolean;
|
||||
error: string | null;
|
||||
// State
|
||||
user: UserData | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
isInitialized: boolean;
|
||||
error: string | null;
|
||||
|
||||
// Actions
|
||||
initialize: () => Promise<void>;
|
||||
signIn: (email: string, password: string) => Promise<{ success: boolean; error?: string }>;
|
||||
signUp: (email: string, password: string) => Promise<{ success: boolean; error?: string; needsVerification?: boolean }>;
|
||||
signInWithGoogle: (idToken: string) => Promise<{ success: boolean; error?: string }>;
|
||||
signInWithApple: (idToken: string, user?: { email?: string; fullName?: { givenName?: string; familyName?: string } }) => Promise<{ success: boolean; error?: string }>;
|
||||
signOut: () => Promise<void>;
|
||||
forgotPassword: (email: string) => Promise<{ success: boolean; error?: string }>;
|
||||
refreshAuth: () => Promise<boolean>;
|
||||
clearError: () => void;
|
||||
// Actions
|
||||
initialize: () => Promise<void>;
|
||||
signIn: (email: string, password: string) => Promise<{ success: boolean; error?: string }>;
|
||||
signUp: (
|
||||
email: string,
|
||||
password: string
|
||||
) => Promise<{ success: boolean; error?: string; needsVerification?: boolean }>;
|
||||
signInWithGoogle: (idToken: string) => Promise<{ success: boolean; error?: string }>;
|
||||
signInWithApple: (
|
||||
idToken: string,
|
||||
user?: { email?: string; fullName?: { givenName?: string; familyName?: string } }
|
||||
) => Promise<{ success: boolean; error?: string }>;
|
||||
signOut: () => Promise<void>;
|
||||
forgotPassword: (email: string) => Promise<{ success: boolean; error?: string }>;
|
||||
refreshAuth: () => Promise<boolean>;
|
||||
clearError: () => void;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
isInitialized: false,
|
||||
error: null,
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
isInitialized: false,
|
||||
error: null,
|
||||
|
||||
/**
|
||||
* Initialize auth state from stored tokens
|
||||
*/
|
||||
initialize: async () => {
|
||||
if (get().isInitialized) return;
|
||||
/**
|
||||
* Initialize auth state from stored tokens
|
||||
*/
|
||||
initialize: async () => {
|
||||
if (get().isInitialized) return;
|
||||
|
||||
set({ isLoading: true });
|
||||
set({ isLoading: true });
|
||||
|
||||
try {
|
||||
const token = await tokenManager.getAppToken();
|
||||
try {
|
||||
const token = await tokenManager.getAppToken();
|
||||
|
||||
if (!token) {
|
||||
set({ user: null, isAuthenticated: false, isLoading: false, isInitialized: true });
|
||||
return;
|
||||
}
|
||||
if (!token) {
|
||||
set({ user: null, isAuthenticated: false, isLoading: false, isInitialized: true });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if token is still valid
|
||||
if (authService.isTokenValidLocally(token)) {
|
||||
const userData = authService.getUserFromToken(token);
|
||||
if (userData) {
|
||||
set({ user: userData, isAuthenticated: true, isLoading: false, isInitialized: true });
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Check if token is still valid
|
||||
if (authService.isTokenValidLocally(token)) {
|
||||
const userData = authService.getUserFromToken(token);
|
||||
if (userData) {
|
||||
set({ user: userData, isAuthenticated: true, isLoading: false, isInitialized: true });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Try to refresh token
|
||||
const refreshToken = await tokenManager.getRefreshToken();
|
||||
if (refreshToken) {
|
||||
try {
|
||||
const result = await authService.refreshTokens(refreshToken);
|
||||
if (result.appToken && result.refreshToken) {
|
||||
await tokenManager.setAppToken(result.appToken);
|
||||
await tokenManager.setRefreshToken(result.refreshToken);
|
||||
// Try to refresh token
|
||||
const refreshToken = await tokenManager.getRefreshToken();
|
||||
if (refreshToken) {
|
||||
try {
|
||||
const result = await authService.refreshTokens(refreshToken);
|
||||
if (result.appToken && result.refreshToken) {
|
||||
await tokenManager.setAppToken(result.appToken);
|
||||
await tokenManager.setRefreshToken(result.refreshToken);
|
||||
|
||||
const userData = authService.getUserFromToken(result.appToken);
|
||||
if (userData) {
|
||||
set({ user: userData, isAuthenticated: true, isLoading: false, isInitialized: true });
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh token on init:', error);
|
||||
}
|
||||
}
|
||||
const userData = authService.getUserFromToken(result.appToken);
|
||||
if (userData) {
|
||||
set({ user: userData, isAuthenticated: true, isLoading: false, isInitialized: true });
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh token on init:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear invalid tokens
|
||||
await tokenManager.clearTokens();
|
||||
set({ user: null, isAuthenticated: false, isLoading: false, isInitialized: true });
|
||||
} catch (error) {
|
||||
console.error('Error initializing auth:', error);
|
||||
set({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
isInitialized: true,
|
||||
error: 'Failed to initialize authentication',
|
||||
});
|
||||
}
|
||||
},
|
||||
// Clear invalid tokens
|
||||
await tokenManager.clearTokens();
|
||||
set({ user: null, isAuthenticated: false, isLoading: false, isInitialized: true });
|
||||
} catch (error) {
|
||||
console.error('Error initializing auth:', error);
|
||||
set({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
isInitialized: true,
|
||||
error: 'Failed to initialize authentication',
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Sign in with email and password
|
||||
*/
|
||||
signIn: async (email: string, password: string) => {
|
||||
set({ isLoading: true, error: null });
|
||||
/**
|
||||
* Sign in with email and password
|
||||
*/
|
||||
signIn: async (email: string, password: string) => {
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
try {
|
||||
const result = await authService.signIn(email, password);
|
||||
try {
|
||||
const result = await authService.signIn(email, password);
|
||||
|
||||
if (!result.success) {
|
||||
set({ isLoading: false, error: result.error || 'Sign in failed' });
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
if (!result.success) {
|
||||
set({ isLoading: false, error: result.error || 'Sign in failed' });
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
|
||||
if (result.appToken && result.refreshToken) {
|
||||
await tokenManager.setAppToken(result.appToken);
|
||||
await tokenManager.setRefreshToken(result.refreshToken);
|
||||
if (result.email) {
|
||||
await tokenManager.setUserEmail(result.email);
|
||||
}
|
||||
if (result.appToken && result.refreshToken) {
|
||||
await tokenManager.setAppToken(result.appToken);
|
||||
await tokenManager.setRefreshToken(result.refreshToken);
|
||||
if (result.email) {
|
||||
await tokenManager.setUserEmail(result.email);
|
||||
}
|
||||
|
||||
const userData = authService.getUserFromToken(result.appToken);
|
||||
if (userData) {
|
||||
set({ user: userData, isAuthenticated: true, isLoading: false, error: null });
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
const userData = authService.getUserFromToken(result.appToken);
|
||||
if (userData) {
|
||||
set({ user: userData, isAuthenticated: true, isLoading: false, error: null });
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Invalid auth response');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error during sign in';
|
||||
set({ isLoading: false, error: errorMessage });
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
},
|
||||
throw new Error('Invalid auth response');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error during sign in';
|
||||
set({ isLoading: false, error: errorMessage });
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Sign up with email and password
|
||||
*/
|
||||
signUp: async (email: string, password: string) => {
|
||||
set({ isLoading: true, error: null });
|
||||
/**
|
||||
* Sign up with email and password
|
||||
*/
|
||||
signUp: async (email: string, password: string) => {
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
try {
|
||||
const result = await authService.signUp(email, password);
|
||||
try {
|
||||
const result = await authService.signUp(email, password);
|
||||
|
||||
if (!result.success) {
|
||||
set({ isLoading: false, error: result.error || 'Sign up failed' });
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
if (!result.success) {
|
||||
set({ isLoading: false, error: result.error || 'Sign up failed' });
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
|
||||
if (result.needsVerification) {
|
||||
set({ isLoading: false, error: null });
|
||||
return { success: true, needsVerification: true };
|
||||
}
|
||||
if (result.needsVerification) {
|
||||
set({ isLoading: false, error: null });
|
||||
return { success: true, needsVerification: true };
|
||||
}
|
||||
|
||||
if (result.appToken && result.refreshToken) {
|
||||
await tokenManager.setAppToken(result.appToken);
|
||||
await tokenManager.setRefreshToken(result.refreshToken);
|
||||
if (result.email) {
|
||||
await tokenManager.setUserEmail(result.email);
|
||||
}
|
||||
if (result.appToken && result.refreshToken) {
|
||||
await tokenManager.setAppToken(result.appToken);
|
||||
await tokenManager.setRefreshToken(result.refreshToken);
|
||||
if (result.email) {
|
||||
await tokenManager.setUserEmail(result.email);
|
||||
}
|
||||
|
||||
const userData = authService.getUserFromToken(result.appToken);
|
||||
if (userData) {
|
||||
set({ user: userData, isAuthenticated: true, isLoading: false, error: null });
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
const userData = authService.getUserFromToken(result.appToken);
|
||||
if (userData) {
|
||||
set({ user: userData, isAuthenticated: true, isLoading: false, error: null });
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Invalid auth response');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error during sign up';
|
||||
set({ isLoading: false, error: errorMessage });
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
},
|
||||
throw new Error('Invalid auth response');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error during sign up';
|
||||
set({ isLoading: false, error: errorMessage });
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Sign in with Google
|
||||
*/
|
||||
signInWithGoogle: async (idToken: string) => {
|
||||
set({ isLoading: true, error: null });
|
||||
/**
|
||||
* Sign in with Google
|
||||
*/
|
||||
signInWithGoogle: async (idToken: string) => {
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
try {
|
||||
const result = await authService.signInWithGoogle(idToken);
|
||||
try {
|
||||
const result = await authService.signInWithGoogle(idToken);
|
||||
|
||||
if (!result.success) {
|
||||
set({ isLoading: false, error: result.error || 'Google Sign-In failed' });
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
if (!result.success) {
|
||||
set({ isLoading: false, error: result.error || 'Google Sign-In failed' });
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
|
||||
if (result.appToken && result.refreshToken) {
|
||||
await tokenManager.setAppToken(result.appToken);
|
||||
await tokenManager.setRefreshToken(result.refreshToken);
|
||||
if (result.email) {
|
||||
await tokenManager.setUserEmail(result.email);
|
||||
}
|
||||
if (result.appToken && result.refreshToken) {
|
||||
await tokenManager.setAppToken(result.appToken);
|
||||
await tokenManager.setRefreshToken(result.refreshToken);
|
||||
if (result.email) {
|
||||
await tokenManager.setUserEmail(result.email);
|
||||
}
|
||||
|
||||
const userData = authService.getUserFromToken(result.appToken);
|
||||
if (userData) {
|
||||
set({ user: userData, isAuthenticated: true, isLoading: false, error: null });
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
const userData = authService.getUserFromToken(result.appToken);
|
||||
if (userData) {
|
||||
set({ user: userData, isAuthenticated: true, isLoading: false, error: null });
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Invalid auth response');
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Unknown error during Google Sign-In';
|
||||
set({ isLoading: false, error: errorMessage });
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
},
|
||||
throw new Error('Invalid auth response');
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Unknown error during Google Sign-In';
|
||||
set({ isLoading: false, error: errorMessage });
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Sign in with Apple
|
||||
*/
|
||||
signInWithApple: async (idToken: string, user?: { email?: string; fullName?: { givenName?: string; familyName?: string } }) => {
|
||||
set({ isLoading: true, error: null });
|
||||
/**
|
||||
* Sign in with Apple
|
||||
*/
|
||||
signInWithApple: async (
|
||||
idToken: string,
|
||||
user?: { email?: string; fullName?: { givenName?: string; familyName?: string } }
|
||||
) => {
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
try {
|
||||
const result = await authService.signInWithApple(idToken, user);
|
||||
try {
|
||||
const result = await authService.signInWithApple(idToken, user);
|
||||
|
||||
if (!result.success) {
|
||||
set({ isLoading: false, error: result.error || 'Apple Sign-In failed' });
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
if (!result.success) {
|
||||
set({ isLoading: false, error: result.error || 'Apple Sign-In failed' });
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
|
||||
if (result.appToken && result.refreshToken) {
|
||||
await tokenManager.setAppToken(result.appToken);
|
||||
await tokenManager.setRefreshToken(result.refreshToken);
|
||||
if (result.email) {
|
||||
await tokenManager.setUserEmail(result.email);
|
||||
}
|
||||
if (result.appToken && result.refreshToken) {
|
||||
await tokenManager.setAppToken(result.appToken);
|
||||
await tokenManager.setRefreshToken(result.refreshToken);
|
||||
if (result.email) {
|
||||
await tokenManager.setUserEmail(result.email);
|
||||
}
|
||||
|
||||
const userData = authService.getUserFromToken(result.appToken);
|
||||
if (userData) {
|
||||
set({ user: userData, isAuthenticated: true, isLoading: false, error: null });
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
const userData = authService.getUserFromToken(result.appToken);
|
||||
if (userData) {
|
||||
set({ user: userData, isAuthenticated: true, isLoading: false, error: null });
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Invalid auth response');
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Unknown error during Apple Sign-In';
|
||||
set({ isLoading: false, error: errorMessage });
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
},
|
||||
throw new Error('Invalid auth response');
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Unknown error during Apple Sign-In';
|
||||
set({ isLoading: false, error: errorMessage });
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Sign out
|
||||
*/
|
||||
signOut: async () => {
|
||||
set({ isLoading: true });
|
||||
/**
|
||||
* Sign out
|
||||
*/
|
||||
signOut: async () => {
|
||||
set({ isLoading: true });
|
||||
|
||||
try {
|
||||
const refreshToken = await tokenManager.getRefreshToken();
|
||||
if (refreshToken) {
|
||||
await authService.signOut(refreshToken);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error during sign out:', error);
|
||||
} finally {
|
||||
await tokenManager.clearTokens();
|
||||
set({ user: null, isAuthenticated: false, isLoading: false, error: null });
|
||||
}
|
||||
},
|
||||
try {
|
||||
const refreshToken = await tokenManager.getRefreshToken();
|
||||
if (refreshToken) {
|
||||
await authService.signOut(refreshToken);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error during sign out:', error);
|
||||
} finally {
|
||||
await tokenManager.clearTokens();
|
||||
set({ user: null, isAuthenticated: false, isLoading: false, error: null });
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Forgot password
|
||||
*/
|
||||
forgotPassword: async (email: string) => {
|
||||
return authService.forgotPassword(email);
|
||||
},
|
||||
/**
|
||||
* Forgot password
|
||||
*/
|
||||
forgotPassword: async (email: string) => {
|
||||
return authService.forgotPassword(email);
|
||||
},
|
||||
|
||||
/**
|
||||
* Refresh authentication tokens
|
||||
*/
|
||||
refreshAuth: async () => {
|
||||
try {
|
||||
const refreshToken = await tokenManager.getRefreshToken();
|
||||
if (!refreshToken) {
|
||||
return false;
|
||||
}
|
||||
/**
|
||||
* Refresh authentication tokens
|
||||
*/
|
||||
refreshAuth: async () => {
|
||||
try {
|
||||
const refreshToken = await tokenManager.getRefreshToken();
|
||||
if (!refreshToken) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const result = await authService.refreshTokens(refreshToken);
|
||||
if (result.appToken && result.refreshToken) {
|
||||
await tokenManager.setAppToken(result.appToken);
|
||||
await tokenManager.setRefreshToken(result.refreshToken);
|
||||
const result = await authService.refreshTokens(refreshToken);
|
||||
if (result.appToken && result.refreshToken) {
|
||||
await tokenManager.setAppToken(result.appToken);
|
||||
await tokenManager.setRefreshToken(result.refreshToken);
|
||||
|
||||
if (result.userData) {
|
||||
set({ user: result.userData });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('Error refreshing auth:', error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
if (result.userData) {
|
||||
set({ user: result.userData });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('Error refreshing auth:', error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear error state
|
||||
*/
|
||||
clearError: () => set({ error: null }),
|
||||
/**
|
||||
* Clear error state
|
||||
*/
|
||||
clearError: () => set({ error: null }),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -3,188 +3,188 @@ import { Meal, MealWithItems, CreateMealInput, CreateFoodItemInput } from '../ty
|
|||
import { SQLiteService } from '../services/database/SQLiteService';
|
||||
|
||||
interface MealState {
|
||||
meals: MealWithItems[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
selectedMeal: MealWithItems | null;
|
||||
meals: MealWithItems[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
selectedMeal: MealWithItems | null;
|
||||
|
||||
// Actions
|
||||
loadMeals: () => Promise<void>;
|
||||
loadMealById: (id: number) => Promise<void>;
|
||||
createMeal: (input: CreateMealInput) => Promise<number>;
|
||||
updateMeal: (id: number, updates: Partial<Meal>) => Promise<void>;
|
||||
deleteMeal: (id: number) => Promise<void>;
|
||||
createFoodItem: (input: CreateFoodItemInput) => Promise<number>;
|
||||
createFoodItemsBatch: (inputs: CreateFoodItemInput[]) => Promise<number[]>;
|
||||
searchMeals: (query: string) => Promise<void>;
|
||||
clearError: () => void;
|
||||
setSelectedMeal: (meal: MealWithItems | null) => void;
|
||||
clearAllMeals: () => void;
|
||||
// Actions
|
||||
loadMeals: () => Promise<void>;
|
||||
loadMealById: (id: number) => Promise<void>;
|
||||
createMeal: (input: CreateMealInput) => Promise<number>;
|
||||
updateMeal: (id: number, updates: Partial<Meal>) => Promise<void>;
|
||||
deleteMeal: (id: number) => Promise<void>;
|
||||
createFoodItem: (input: CreateFoodItemInput) => Promise<number>;
|
||||
createFoodItemsBatch: (inputs: CreateFoodItemInput[]) => Promise<number[]>;
|
||||
searchMeals: (query: string) => Promise<void>;
|
||||
clearError: () => void;
|
||||
setSelectedMeal: (meal: MealWithItems | null) => void;
|
||||
clearAllMeals: () => void;
|
||||
}
|
||||
|
||||
export const useMealStore = create<MealState>((set, get) => ({
|
||||
meals: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
selectedMeal: null,
|
||||
meals: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
selectedMeal: null,
|
||||
|
||||
loadMeals: async () => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const dbService = SQLiteService.getInstance();
|
||||
const meals = await dbService.getAllMealsWithItems(50, 0);
|
||||
set({ meals, isLoading: false });
|
||||
} catch (error) {
|
||||
set({
|
||||
error: error instanceof Error ? error.message : 'Failed to load meals',
|
||||
isLoading: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
loadMeals: async () => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const dbService = SQLiteService.getInstance();
|
||||
const meals = await dbService.getAllMealsWithItems(50, 0);
|
||||
set({ meals, isLoading: false });
|
||||
} catch (error) {
|
||||
set({
|
||||
error: error instanceof Error ? error.message : 'Failed to load meals',
|
||||
isLoading: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
loadMealById: async (id: number) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const dbService = SQLiteService.getInstance();
|
||||
const meal = await dbService.getMealWithItems(id);
|
||||
console.log(`Loaded meal ${id} with photo_path:`, meal?.photo_path);
|
||||
set({ selectedMeal: meal, isLoading: false });
|
||||
} catch (error) {
|
||||
set({
|
||||
error: error instanceof Error ? error.message : 'Failed to load meal',
|
||||
isLoading: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
loadMealById: async (id: number) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const dbService = SQLiteService.getInstance();
|
||||
const meal = await dbService.getMealWithItems(id);
|
||||
console.log(`Loaded meal ${id} with photo_path:`, meal?.photo_path);
|
||||
set({ selectedMeal: meal, isLoading: false });
|
||||
} catch (error) {
|
||||
set({
|
||||
error: error instanceof Error ? error.message : 'Failed to load meal',
|
||||
isLoading: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
createMeal: async (input: CreateMealInput) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const dbService = SQLiteService.getInstance();
|
||||
const mealId = await dbService.createMeal(input);
|
||||
createMeal: async (input: CreateMealInput) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const dbService = SQLiteService.getInstance();
|
||||
const mealId = await dbService.createMeal(input);
|
||||
|
||||
// Reload meals to update the list
|
||||
await get().loadMeals();
|
||||
// Reload meals to update the list
|
||||
await get().loadMeals();
|
||||
|
||||
set({ isLoading: false });
|
||||
return mealId;
|
||||
} catch (error) {
|
||||
set({
|
||||
error: error instanceof Error ? error.message : 'Failed to create meal',
|
||||
isLoading: false,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
set({ isLoading: false });
|
||||
return mealId;
|
||||
} catch (error) {
|
||||
set({
|
||||
error: error instanceof Error ? error.message : 'Failed to create meal',
|
||||
isLoading: false,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
updateMeal: async (id: number, updates: Partial<Meal>) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const dbService = SQLiteService.getInstance();
|
||||
await dbService.updateMeal(id, updates);
|
||||
updateMeal: async (id: number, updates: Partial<Meal>) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const dbService = SQLiteService.getInstance();
|
||||
await dbService.updateMeal(id, updates);
|
||||
|
||||
// If this is a completed analysis, reload the meal with all food items
|
||||
if (updates.analysis_status === 'completed') {
|
||||
const updatedMealWithItems = await dbService.getMealWithItems(id);
|
||||
// If this is a completed analysis, reload the meal with all food items
|
||||
if (updates.analysis_status === 'completed') {
|
||||
const updatedMealWithItems = await dbService.getMealWithItems(id);
|
||||
|
||||
// Update meals in store with the full meal data
|
||||
const meals = get().meals.map((meal) => (meal.id === id ? updatedMealWithItems : meal));
|
||||
// Update meals in store with the full meal data
|
||||
const meals = get().meals.map((meal) => (meal.id === id ? updatedMealWithItems : meal));
|
||||
|
||||
// Update selected meal if it's the one being updated
|
||||
const selectedMeal = get().selectedMeal;
|
||||
if (selectedMeal && selectedMeal.id === id) {
|
||||
set({ selectedMeal: updatedMealWithItems });
|
||||
}
|
||||
// Update selected meal if it's the one being updated
|
||||
const selectedMeal = get().selectedMeal;
|
||||
if (selectedMeal && selectedMeal.id === id) {
|
||||
set({ selectedMeal: updatedMealWithItems });
|
||||
}
|
||||
|
||||
set({ meals, isLoading: false });
|
||||
} else {
|
||||
// For other updates, just update the fields we have
|
||||
const meals = get().meals.map((meal) => (meal.id === id ? { ...meal, ...updates } : meal));
|
||||
set({ meals, isLoading: false });
|
||||
} else {
|
||||
// For other updates, just update the fields we have
|
||||
const meals = get().meals.map((meal) => (meal.id === id ? { ...meal, ...updates } : meal));
|
||||
|
||||
// Update selected meal if it's the one being updated
|
||||
const selectedMeal = get().selectedMeal;
|
||||
if (selectedMeal && selectedMeal.id === id) {
|
||||
set({ selectedMeal: { ...selectedMeal, ...updates } });
|
||||
}
|
||||
// Update selected meal if it's the one being updated
|
||||
const selectedMeal = get().selectedMeal;
|
||||
if (selectedMeal && selectedMeal.id === id) {
|
||||
set({ selectedMeal: { ...selectedMeal, ...updates } });
|
||||
}
|
||||
|
||||
set({ meals, isLoading: false });
|
||||
}
|
||||
set({ meals, isLoading: false });
|
||||
}
|
||||
|
||||
console.log(`Meal ${id} updated with:`, updates);
|
||||
} catch (error) {
|
||||
set({
|
||||
error: error instanceof Error ? error.message : 'Failed to update meal',
|
||||
isLoading: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
console.log(`Meal ${id} updated with:`, updates);
|
||||
} catch (error) {
|
||||
set({
|
||||
error: error instanceof Error ? error.message : 'Failed to update meal',
|
||||
isLoading: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
deleteMeal: async (id: number) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const dbService = SQLiteService.getInstance();
|
||||
await dbService.deleteMeal(id);
|
||||
deleteMeal: async (id: number) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const dbService = SQLiteService.getInstance();
|
||||
await dbService.deleteMeal(id);
|
||||
|
||||
// Remove from meals array
|
||||
const meals = get().meals.filter((meal) => meal.id !== id);
|
||||
// Remove from meals array
|
||||
const meals = get().meals.filter((meal) => meal.id !== id);
|
||||
|
||||
// Clear selected meal if it was deleted
|
||||
const selectedMeal = get().selectedMeal;
|
||||
if (selectedMeal && selectedMeal.id === id) {
|
||||
set({ selectedMeal: null });
|
||||
}
|
||||
// Clear selected meal if it was deleted
|
||||
const selectedMeal = get().selectedMeal;
|
||||
if (selectedMeal && selectedMeal.id === id) {
|
||||
set({ selectedMeal: null });
|
||||
}
|
||||
|
||||
set({ meals, isLoading: false });
|
||||
} catch (error) {
|
||||
set({
|
||||
error: error instanceof Error ? error.message : 'Failed to delete meal',
|
||||
isLoading: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
set({ meals, isLoading: false });
|
||||
} catch (error) {
|
||||
set({
|
||||
error: error instanceof Error ? error.message : 'Failed to delete meal',
|
||||
isLoading: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
searchMeals: async (query: string) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const dbService = SQLiteService.getInstance();
|
||||
const meals =
|
||||
query.trim() === ''
|
||||
? await dbService.getAllMeals(50, 0)
|
||||
: await dbService.searchMeals(query);
|
||||
set({ meals, isLoading: false });
|
||||
} catch (error) {
|
||||
set({
|
||||
error: error instanceof Error ? error.message : 'Failed to search meals',
|
||||
isLoading: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
searchMeals: async (query: string) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const dbService = SQLiteService.getInstance();
|
||||
const meals =
|
||||
query.trim() === ''
|
||||
? await dbService.getAllMeals(50, 0)
|
||||
: await dbService.searchMeals(query);
|
||||
set({ meals, isLoading: false });
|
||||
} catch (error) {
|
||||
set({
|
||||
error: error instanceof Error ? error.message : 'Failed to search meals',
|
||||
isLoading: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
createFoodItem: async (input: CreateFoodItemInput) => {
|
||||
try {
|
||||
const dbService = SQLiteService.getInstance();
|
||||
const foodItemId = await dbService.createFoodItem(input);
|
||||
return foodItemId;
|
||||
} catch (error) {
|
||||
console.error('Failed to create food item:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
createFoodItem: async (input: CreateFoodItemInput) => {
|
||||
try {
|
||||
const dbService = SQLiteService.getInstance();
|
||||
const foodItemId = await dbService.createFoodItem(input);
|
||||
return foodItemId;
|
||||
} catch (error) {
|
||||
console.error('Failed to create food item:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
createFoodItemsBatch: async (inputs: CreateFoodItemInput[]) => {
|
||||
try {
|
||||
const dbService = SQLiteService.getInstance();
|
||||
const foodItemIds = await dbService.createFoodItemsBatch(inputs);
|
||||
return foodItemIds;
|
||||
} catch (error) {
|
||||
console.error('Failed to create food items batch:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
createFoodItemsBatch: async (inputs: CreateFoodItemInput[]) => {
|
||||
try {
|
||||
const dbService = SQLiteService.getInstance();
|
||||
const foodItemIds = await dbService.createFoodItemsBatch(inputs);
|
||||
return foodItemIds;
|
||||
} catch (error) {
|
||||
console.error('Failed to create food items batch:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
clearError: () => set({ error: null }),
|
||||
clearError: () => set({ error: null }),
|
||||
|
||||
setSelectedMeal: (meal: MealWithItems | null) => set({ selectedMeal: meal }),
|
||||
setSelectedMeal: (meal: MealWithItems | null) => set({ selectedMeal: meal }),
|
||||
|
||||
clearAllMeals: () => set({ meals: [], selectedMeal: null, error: null }),
|
||||
clearAllMeals: () => set({ meals: [], selectedMeal: null, error: null }),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: ['./app/**/*.{js,ts,tsx}', './components/**/*.{js,ts,tsx}'],
|
||||
content: ['./app/**/*.{js,ts,tsx}', './components/**/*.{js,ts,tsx}'],
|
||||
|
||||
presets: [require('nativewind/preset')],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
presets: [require('nativewind/preset')],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"jsx": "react-jsx",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"~/*": ["*"],
|
||||
"@/*": ["*"]
|
||||
}
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts", "nativewind-env.d.ts"]
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"jsx": "react-jsx",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"~/*": ["*"],
|
||||
"@/*": ["*"]
|
||||
}
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts", "nativewind-env.d.ts"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,97 +1,97 @@
|
|||
// Gemini API Response Typen
|
||||
export interface GeminiAnalysisResult {
|
||||
meal_analysis: {
|
||||
total_calories: number;
|
||||
total_protein: number;
|
||||
total_carbs: number;
|
||||
total_fat: number;
|
||||
total_fiber?: number;
|
||||
total_sugar?: number;
|
||||
health_score: number; // 1.0-10.0
|
||||
health_category: 'healthy' | 'moderate' | 'unhealthy';
|
||||
confidence: number; // 0.0-1.0
|
||||
meal_type_suggestion?: 'breakfast' | 'lunch' | 'dinner' | 'snack';
|
||||
};
|
||||
food_items: GeminiFoodItem[];
|
||||
analysis_notes: {
|
||||
health_reasoning: string;
|
||||
improvement_suggestions: string[];
|
||||
cooking_method: string;
|
||||
estimated_freshness: string;
|
||||
hidden_ingredients: string[];
|
||||
portion_accuracy: 'low' | 'medium' | 'high';
|
||||
};
|
||||
_metadata?: {
|
||||
processingTime: number;
|
||||
apiProvider: string;
|
||||
model: string;
|
||||
timestamp: string;
|
||||
};
|
||||
meal_analysis: {
|
||||
total_calories: number;
|
||||
total_protein: number;
|
||||
total_carbs: number;
|
||||
total_fat: number;
|
||||
total_fiber?: number;
|
||||
total_sugar?: number;
|
||||
health_score: number; // 1.0-10.0
|
||||
health_category: 'healthy' | 'moderate' | 'unhealthy';
|
||||
confidence: number; // 0.0-1.0
|
||||
meal_type_suggestion?: 'breakfast' | 'lunch' | 'dinner' | 'snack';
|
||||
};
|
||||
food_items: GeminiFoodItem[];
|
||||
analysis_notes: {
|
||||
health_reasoning: string;
|
||||
improvement_suggestions: string[];
|
||||
cooking_method: string;
|
||||
estimated_freshness: string;
|
||||
hidden_ingredients: string[];
|
||||
portion_accuracy: 'low' | 'medium' | 'high';
|
||||
};
|
||||
_metadata?: {
|
||||
processingTime: number;
|
||||
apiProvider: string;
|
||||
model: string;
|
||||
timestamp: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface GeminiFoodItem {
|
||||
name: string;
|
||||
category:
|
||||
| 'protein'
|
||||
| 'vegetable'
|
||||
| 'grain'
|
||||
| 'fruit'
|
||||
| 'dairy'
|
||||
| 'fat'
|
||||
| 'processed'
|
||||
| 'beverage';
|
||||
portion_size: string;
|
||||
calories: number;
|
||||
protein: number;
|
||||
carbs: number;
|
||||
fat: number;
|
||||
fiber?: number;
|
||||
sugar?: number;
|
||||
confidence: number;
|
||||
is_organic: boolean;
|
||||
is_processed: boolean;
|
||||
allergens: string[];
|
||||
name: string;
|
||||
category:
|
||||
| 'protein'
|
||||
| 'vegetable'
|
||||
| 'grain'
|
||||
| 'fruit'
|
||||
| 'dairy'
|
||||
| 'fat'
|
||||
| 'processed'
|
||||
| 'beverage';
|
||||
portion_size: string;
|
||||
calories: number;
|
||||
protein: number;
|
||||
carbs: number;
|
||||
fat: number;
|
||||
fiber?: number;
|
||||
sugar?: number;
|
||||
confidence: number;
|
||||
is_organic: boolean;
|
||||
is_processed: boolean;
|
||||
allergens: string[];
|
||||
}
|
||||
|
||||
// API Error Types
|
||||
export interface APIError {
|
||||
code: string;
|
||||
message: string;
|
||||
details?: any;
|
||||
code: string;
|
||||
message: string;
|
||||
details?: any;
|
||||
}
|
||||
|
||||
// Prompt Context Types
|
||||
export interface PromptContext {
|
||||
mealType?: 'breakfast' | 'lunch' | 'dinner' | 'snack';
|
||||
location?: 'restaurant' | 'homemade' | 'fastfood';
|
||||
additional?: string;
|
||||
mealType?: 'breakfast' | 'lunch' | 'dinner' | 'snack';
|
||||
location?: 'restaurant' | 'homemade' | 'fastfood';
|
||||
additional?: string;
|
||||
}
|
||||
|
||||
// Gemini Error Class
|
||||
export class GeminiError extends Error {
|
||||
public readonly code: string;
|
||||
public readonly type: 'TEMPORARY' | 'PERMANENT';
|
||||
public readonly metadata?: any;
|
||||
public readonly code: string;
|
||||
public readonly type: 'TEMPORARY' | 'PERMANENT';
|
||||
public readonly metadata?: any;
|
||||
|
||||
constructor(message: string, code: string, type: 'TEMPORARY' | 'PERMANENT', metadata?: any) {
|
||||
super(message);
|
||||
this.name = 'GeminiError';
|
||||
this.code = code;
|
||||
this.type = type;
|
||||
this.metadata = metadata;
|
||||
}
|
||||
constructor(message: string, code: string, type: 'TEMPORARY' | 'PERMANENT', metadata?: any) {
|
||||
super(message);
|
||||
this.name = 'GeminiError';
|
||||
this.code = code;
|
||||
this.type = type;
|
||||
this.metadata = metadata;
|
||||
}
|
||||
}
|
||||
|
||||
export interface AnalysisRequest {
|
||||
imageBase64: string;
|
||||
context?: PromptContext;
|
||||
mealType?: 'breakfast' | 'lunch' | 'dinner' | 'snack';
|
||||
imageBase64: string;
|
||||
context?: PromptContext;
|
||||
mealType?: 'breakfast' | 'lunch' | 'dinner' | 'snack';
|
||||
}
|
||||
|
||||
export interface AnalysisResponse {
|
||||
success: boolean;
|
||||
data?: GeminiAnalysisResult;
|
||||
error?: APIError;
|
||||
processingTime: number;
|
||||
cost?: number;
|
||||
success: boolean;
|
||||
data?: GeminiAnalysisResult;
|
||||
error?: APIError;
|
||||
processingTime: number;
|
||||
cost?: number;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,116 +1,116 @@
|
|||
export interface Meal {
|
||||
id?: number;
|
||||
cloud_id?: string;
|
||||
user_id?: string;
|
||||
sync_status: 'local' | 'synced' | 'conflict' | 'pending';
|
||||
version: number;
|
||||
last_sync_at?: string;
|
||||
photo_path: string;
|
||||
photo_url?: string;
|
||||
photo_size?: number;
|
||||
photo_dimensions?: string; // JSON: {"width": 1920, "height": 1080}
|
||||
timestamp: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
meal_type?: 'breakfast' | 'lunch' | 'dinner' | 'snack';
|
||||
location?: string;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
location_accuracy?: number;
|
||||
analysis_result?: string; // JSON der Gemini-Antwort
|
||||
analysis_confidence?: number;
|
||||
analysis_status: 'pending' | 'completed' | 'failed' | 'manual';
|
||||
total_calories?: number;
|
||||
total_protein?: number;
|
||||
total_carbs?: number;
|
||||
total_fat?: number;
|
||||
total_fiber?: number;
|
||||
total_sugar?: number;
|
||||
health_score?: number; // 1.0 - 10.0
|
||||
health_category?: 'very_healthy' | 'healthy' | 'moderate' | 'unhealthy';
|
||||
user_notes?: string;
|
||||
user_modified: number; // Boolean als Integer
|
||||
user_rating?: number; // 1-5 Sterne
|
||||
api_provider: string;
|
||||
api_cost?: number; // Kosten in Cent
|
||||
processing_time?: number; // Millisekunden
|
||||
id?: number;
|
||||
cloud_id?: string;
|
||||
user_id?: string;
|
||||
sync_status: 'local' | 'synced' | 'conflict' | 'pending';
|
||||
version: number;
|
||||
last_sync_at?: string;
|
||||
photo_path: string;
|
||||
photo_url?: string;
|
||||
photo_size?: number;
|
||||
photo_dimensions?: string; // JSON: {"width": 1920, "height": 1080}
|
||||
timestamp: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
meal_type?: 'breakfast' | 'lunch' | 'dinner' | 'snack';
|
||||
location?: string;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
location_accuracy?: number;
|
||||
analysis_result?: string; // JSON der Gemini-Antwort
|
||||
analysis_confidence?: number;
|
||||
analysis_status: 'pending' | 'completed' | 'failed' | 'manual';
|
||||
total_calories?: number;
|
||||
total_protein?: number;
|
||||
total_carbs?: number;
|
||||
total_fat?: number;
|
||||
total_fiber?: number;
|
||||
total_sugar?: number;
|
||||
health_score?: number; // 1.0 - 10.0
|
||||
health_category?: 'very_healthy' | 'healthy' | 'moderate' | 'unhealthy';
|
||||
user_notes?: string;
|
||||
user_modified: number; // Boolean als Integer
|
||||
user_rating?: number; // 1-5 Sterne
|
||||
api_provider: string;
|
||||
api_cost?: number; // Kosten in Cent
|
||||
processing_time?: number; // Millisekunden
|
||||
}
|
||||
|
||||
export interface FoodItem {
|
||||
id?: number;
|
||||
cloud_id?: string;
|
||||
meal_id: number;
|
||||
sync_status: 'local' | 'synced' | 'conflict' | 'pending';
|
||||
version: number;
|
||||
name: string;
|
||||
category:
|
||||
| 'protein'
|
||||
| 'vegetable'
|
||||
| 'grain'
|
||||
| 'fruit'
|
||||
| 'dairy'
|
||||
| 'fat'
|
||||
| 'processed'
|
||||
| 'beverage';
|
||||
portion_size: string;
|
||||
calories?: number;
|
||||
protein?: number;
|
||||
carbs?: number;
|
||||
fat?: number;
|
||||
fiber?: number;
|
||||
sugar?: number;
|
||||
confidence?: number; // 0.0 - 1.0
|
||||
bounding_box?: string; // JSON: Position im Bild
|
||||
is_organic: number; // Boolean als Integer
|
||||
is_processed: number; // Boolean als Integer
|
||||
allergens?: string; // JSON Array
|
||||
created_at: string;
|
||||
id?: number;
|
||||
cloud_id?: string;
|
||||
meal_id: number;
|
||||
sync_status: 'local' | 'synced' | 'conflict' | 'pending';
|
||||
version: number;
|
||||
name: string;
|
||||
category:
|
||||
| 'protein'
|
||||
| 'vegetable'
|
||||
| 'grain'
|
||||
| 'fruit'
|
||||
| 'dairy'
|
||||
| 'fat'
|
||||
| 'processed'
|
||||
| 'beverage';
|
||||
portion_size: string;
|
||||
calories?: number;
|
||||
protein?: number;
|
||||
carbs?: number;
|
||||
fat?: number;
|
||||
fiber?: number;
|
||||
sugar?: number;
|
||||
confidence?: number; // 0.0 - 1.0
|
||||
bounding_box?: string; // JSON: Position im Bild
|
||||
is_organic: number; // Boolean als Integer
|
||||
is_processed: number; // Boolean als Integer
|
||||
allergens?: string; // JSON Array
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface SyncMetadata {
|
||||
table_name: string;
|
||||
record_id: number;
|
||||
cloud_id?: string;
|
||||
last_sync_at?: string;
|
||||
conflict_data?: string; // JSON für Konfliktlösung
|
||||
retry_count: number;
|
||||
table_name: string;
|
||||
record_id: number;
|
||||
cloud_id?: string;
|
||||
last_sync_at?: string;
|
||||
conflict_data?: string; // JSON für Konfliktlösung
|
||||
retry_count: number;
|
||||
}
|
||||
|
||||
export interface PhotoDimensions {
|
||||
width: number;
|
||||
height: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
// Eingabe für neue Mahlzeiten
|
||||
export interface CreateMealInput {
|
||||
photo_path: string;
|
||||
photo_size?: number;
|
||||
photo_dimensions?: PhotoDimensions;
|
||||
meal_type?: Meal['meal_type'];
|
||||
location?: string;
|
||||
user_notes?: string;
|
||||
analysis_status?: Meal['analysis_status'];
|
||||
photo_path: string;
|
||||
photo_size?: number;
|
||||
photo_dimensions?: PhotoDimensions;
|
||||
meal_type?: Meal['meal_type'];
|
||||
location?: string;
|
||||
user_notes?: string;
|
||||
analysis_status?: Meal['analysis_status'];
|
||||
}
|
||||
|
||||
// Eingabe für neue Lebensmittel
|
||||
export interface CreateFoodItemInput {
|
||||
meal_id: number;
|
||||
name: string;
|
||||
category?: FoodItem['category'];
|
||||
portion_size?: string;
|
||||
calories?: number;
|
||||
protein?: number;
|
||||
carbs?: number;
|
||||
fat?: number;
|
||||
fiber?: number;
|
||||
sugar?: number;
|
||||
confidence?: number;
|
||||
is_organic?: number;
|
||||
is_processed?: number;
|
||||
allergens?: string;
|
||||
meal_id: number;
|
||||
name: string;
|
||||
category?: FoodItem['category'];
|
||||
portion_size?: string;
|
||||
calories?: number;
|
||||
protein?: number;
|
||||
carbs?: number;
|
||||
fat?: number;
|
||||
fiber?: number;
|
||||
sugar?: number;
|
||||
confidence?: number;
|
||||
is_organic?: number;
|
||||
is_processed?: number;
|
||||
allergens?: string;
|
||||
}
|
||||
|
||||
// Vollständige Mahlzeit mit FoodItems
|
||||
export interface MealWithItems extends Meal {
|
||||
food_items: FoodItem[];
|
||||
food_items: FoodItem[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,40 +1,40 @@
|
|||
{
|
||||
"name": "@nutriphi/web",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"type-check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^6.0.0",
|
||||
"@sveltejs/kit": "^2.43.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.0",
|
||||
"@tailwindcss/postcss": "^4.1.17",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"postcss": "^8.5.6",
|
||||
"svelte": "^5.39.5",
|
||||
"svelte-check": "^4.3.2",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.1.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"@manacore/shared-auth-ui": "workspace:*",
|
||||
"@manacore/shared-branding": "workspace:*",
|
||||
"@manacore/shared-i18n": "workspace:*",
|
||||
"@manacore/shared-icons": "workspace:*",
|
||||
"@manacore/shared-tailwind": "workspace:*",
|
||||
"@manacore/shared-theme": "workspace:*",
|
||||
"@manacore/shared-theme-ui": "workspace:*",
|
||||
"@manacore/shared-ui": "workspace:*",
|
||||
"@manacore/shared-utils": "workspace:*"
|
||||
}
|
||||
"name": "@nutriphi/web",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"type-check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^6.0.0",
|
||||
"@sveltejs/kit": "^2.43.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.0",
|
||||
"@tailwindcss/postcss": "^4.1.17",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"postcss": "^8.5.6",
|
||||
"svelte": "^5.39.5",
|
||||
"svelte-check": "^4.3.2",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.1.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"@manacore/shared-auth-ui": "workspace:*",
|
||||
"@manacore/shared-branding": "workspace:*",
|
||||
"@manacore/shared-i18n": "workspace:*",
|
||||
"@manacore/shared-icons": "workspace:*",
|
||||
"@manacore/shared-tailwind": "workspace:*",
|
||||
"@manacore/shared-theme": "workspace:*",
|
||||
"@manacore/shared-theme-ui": "workspace:*",
|
||||
"@manacore/shared-ui": "workspace:*",
|
||||
"@manacore/shared-utils": "workspace:*"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
22
apps/nutriphi/apps/web/src/app.d.ts
vendored
22
apps/nutriphi/apps/web/src/app.d.ts
vendored
|
|
@ -2,17 +2,17 @@
|
|||
// for information about these interfaces
|
||||
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
interface Locals {
|
||||
// Authentication handled via Mana Middleware (client-side)
|
||||
}
|
||||
interface PageData {
|
||||
// Page data types
|
||||
}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
interface Locals {
|
||||
// Authentication handled via Mana Middleware (client-side)
|
||||
}
|
||||
interface PageData {
|
||||
// Page data types
|
||||
}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
|
|
|
|||
|
|
@ -5,5 +5,5 @@ import { type Handle } from '@sveltejs/kit';
|
|||
* Authentication is handled client-side via Mana Middleware
|
||||
*/
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
return resolve(event);
|
||||
return resolve(event);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,67 +1,67 @@
|
|||
<script lang="ts">
|
||||
import type { FoodItem } from '$lib/types/meal';
|
||||
import type { FoodItem } from '$lib/types/meal';
|
||||
|
||||
interface Props {
|
||||
items: FoodItem[];
|
||||
}
|
||||
interface Props {
|
||||
items: FoodItem[];
|
||||
}
|
||||
|
||||
let { items }: Props = $props();
|
||||
let { items }: Props = $props();
|
||||
|
||||
function getCategoryLabel(category: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
protein: 'Protein',
|
||||
vegetable: 'Gemüse',
|
||||
grain: 'Getreide',
|
||||
fruit: 'Obst',
|
||||
dairy: 'Milchprodukt',
|
||||
fat: 'Fett',
|
||||
processed: 'Verarbeitet',
|
||||
beverage: 'Getränk'
|
||||
};
|
||||
return labels[category] || category;
|
||||
}
|
||||
function getCategoryLabel(category: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
protein: 'Protein',
|
||||
vegetable: 'Gemüse',
|
||||
grain: 'Getreide',
|
||||
fruit: 'Obst',
|
||||
dairy: 'Milchprodukt',
|
||||
fat: 'Fett',
|
||||
processed: 'Verarbeitet',
|
||||
beverage: 'Getränk',
|
||||
};
|
||||
return labels[category] || category;
|
||||
}
|
||||
|
||||
function getCategoryColor(category: string): string {
|
||||
const colors: Record<string, string> = {
|
||||
protein: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
|
||||
vegetable: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
|
||||
grain: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400',
|
||||
fruit: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400',
|
||||
dairy: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
|
||||
fat: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400',
|
||||
processed: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300',
|
||||
beverage: 'bg-cyan-100 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-400'
|
||||
};
|
||||
return colors[category] || 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300';
|
||||
}
|
||||
function getCategoryColor(category: string): string {
|
||||
const colors: Record<string, string> = {
|
||||
protein: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
|
||||
vegetable: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
|
||||
grain: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400',
|
||||
fruit: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400',
|
||||
dairy: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
|
||||
fat: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400',
|
||||
processed: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300',
|
||||
beverage: 'bg-cyan-100 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-400',
|
||||
};
|
||||
return colors[category] || 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300';
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if items.length === 0}
|
||||
<p class="text-center text-gray-500 dark:text-gray-400">Keine Zutaten erkannt</p>
|
||||
<p class="text-center text-gray-500 dark:text-gray-400">Keine Zutaten erkannt</p>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
{#each items as item (item.id)}
|
||||
<div class="flex items-center justify-between rounded-xl bg-gray-50 p-3 dark:bg-gray-700/50">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="rounded-lg px-2 py-1 text-xs font-medium {getCategoryColor(item.category)}">
|
||||
{getCategoryLabel(item.category)}
|
||||
</span>
|
||||
<div>
|
||||
<p class="font-medium text-gray-900 dark:text-white">{item.name}</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">{item.portion_size}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
{#if item.calories}
|
||||
<p class="font-semibold text-gray-900 dark:text-white">
|
||||
{Math.round(item.calories)} kcal
|
||||
</p>
|
||||
{/if}
|
||||
{#if item.confidence}
|
||||
<p class="text-xs text-gray-500">{Math.round(item.confidence * 100)}%</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
{#each items as item (item.id)}
|
||||
<div class="flex items-center justify-between rounded-xl bg-gray-50 p-3 dark:bg-gray-700/50">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="rounded-lg px-2 py-1 text-xs font-medium {getCategoryColor(item.category)}">
|
||||
{getCategoryLabel(item.category)}
|
||||
</span>
|
||||
<div>
|
||||
<p class="font-medium text-gray-900 dark:text-white">{item.name}</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">{item.portion_size}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
{#if item.calories}
|
||||
<p class="font-semibold text-gray-900 dark:text-white">
|
||||
{Math.round(item.calories)} kcal
|
||||
</p>
|
||||
{/if}
|
||||
{#if item.confidence}
|
||||
<p class="text-xs text-gray-500">{Math.round(item.confidence * 100)}%</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -1,104 +1,110 @@
|
|||
<script lang="ts">
|
||||
import type { Meal } from '$lib/types/meal';
|
||||
import type { Meal } from '$lib/types/meal';
|
||||
|
||||
interface Props {
|
||||
meal: Meal;
|
||||
onclick?: () => void;
|
||||
}
|
||||
interface Props {
|
||||
meal: Meal;
|
||||
onclick?: () => void;
|
||||
}
|
||||
|
||||
let { meal, onclick }: Props = $props();
|
||||
let { meal, onclick }: Props = $props();
|
||||
|
||||
function getMealTypeLabel(type: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
breakfast: 'Frühstück',
|
||||
lunch: 'Mittagessen',
|
||||
dinner: 'Abendessen',
|
||||
snack: 'Snack'
|
||||
};
|
||||
return labels[type] || type;
|
||||
}
|
||||
function getMealTypeLabel(type: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
breakfast: 'Frühstück',
|
||||
lunch: 'Mittagessen',
|
||||
dinner: 'Abendessen',
|
||||
snack: 'Snack',
|
||||
};
|
||||
return labels[type] || type;
|
||||
}
|
||||
|
||||
const healthColor = $derived(() => {
|
||||
if (!meal.health_score) return 'text-gray-400';
|
||||
if (meal.health_score >= 8) return 'text-green-500';
|
||||
if (meal.health_score >= 6) return 'text-yellow-500';
|
||||
if (meal.health_score >= 4) return 'text-orange-500';
|
||||
return 'text-red-500';
|
||||
});
|
||||
const healthColor = $derived(() => {
|
||||
if (!meal.health_score) return 'text-gray-400';
|
||||
if (meal.health_score >= 8) return 'text-green-500';
|
||||
if (meal.health_score >= 6) return 'text-yellow-500';
|
||||
if (meal.health_score >= 4) return 'text-orange-500';
|
||||
return 'text-red-500';
|
||||
});
|
||||
|
||||
function formatDate(timestamp: string): string {
|
||||
return new Date(timestamp).toLocaleDateString('de-DE', {
|
||||
weekday: 'short',
|
||||
day: 'numeric',
|
||||
month: 'short'
|
||||
});
|
||||
}
|
||||
function formatDate(timestamp: string): string {
|
||||
return new Date(timestamp).toLocaleDateString('de-DE', {
|
||||
weekday: 'short',
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
});
|
||||
}
|
||||
|
||||
function formatTime(timestamp: string): string {
|
||||
return new Date(timestamp).toLocaleTimeString('de-DE', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
function formatTime(timestamp: string): string {
|
||||
return new Date(timestamp).toLocaleTimeString('de-DE', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<button
|
||||
{onclick}
|
||||
class="group relative aspect-square w-full overflow-hidden rounded-2xl bg-gray-100 transition-transform hover:scale-[1.02] dark:bg-gray-700"
|
||||
{onclick}
|
||||
class="group relative aspect-square w-full overflow-hidden rounded-2xl bg-gray-100 transition-transform hover:scale-[1.02] dark:bg-gray-700"
|
||||
>
|
||||
{#if meal.photo_url}
|
||||
<img
|
||||
src={meal.photo_url}
|
||||
alt={getMealTypeLabel(meal.meal_type)}
|
||||
class="h-full w-full object-cover transition-transform group-hover:scale-105"
|
||||
/>
|
||||
{:else}
|
||||
<div class="flex h-full items-center justify-center text-4xl">🍽️</div>
|
||||
{/if}
|
||||
{#if meal.photo_url}
|
||||
<img
|
||||
src={meal.photo_url}
|
||||
alt={getMealTypeLabel(meal.meal_type)}
|
||||
class="h-full w-full object-cover transition-transform group-hover:scale-105"
|
||||
/>
|
||||
{:else}
|
||||
<div class="flex h-full items-center justify-center text-4xl">🍽️</div>
|
||||
{/if}
|
||||
|
||||
<!-- Overlay -->
|
||||
<div class="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 via-black/40 to-transparent p-4">
|
||||
<div class="flex items-end justify-between">
|
||||
<div>
|
||||
<p class="font-semibold text-white">{getMealTypeLabel(meal.meal_type)}</p>
|
||||
<p class="text-sm text-gray-300">
|
||||
{formatDate(meal.timestamp)} • {formatTime(meal.timestamp)}
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
{#if meal.total_calories}
|
||||
<p class="font-bold text-white">{Math.round(meal.total_calories)}</p>
|
||||
<p class="text-xs text-gray-300">kcal</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Overlay -->
|
||||
<div
|
||||
class="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 via-black/40 to-transparent p-4"
|
||||
>
|
||||
<div class="flex items-end justify-between">
|
||||
<div>
|
||||
<p class="font-semibold text-white">{getMealTypeLabel(meal.meal_type)}</p>
|
||||
<p class="text-sm text-gray-300">
|
||||
{formatDate(meal.timestamp)} • {formatTime(meal.timestamp)}
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
{#if meal.total_calories}
|
||||
<p class="font-bold text-white">{Math.round(meal.total_calories)}</p>
|
||||
<p class="text-xs text-gray-300">kcal</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if meal.health_score}
|
||||
<div class="mt-2 flex items-center gap-1">
|
||||
<div class="h-1.5 flex-1 overflow-hidden rounded-full bg-white/20">
|
||||
<div
|
||||
class="h-full rounded-full {meal.health_score >= 7
|
||||
? 'bg-green-500'
|
||||
: meal.health_score >= 5
|
||||
? 'bg-yellow-500'
|
||||
: 'bg-red-500'}"
|
||||
style="width: {meal.health_score * 10}%"
|
||||
></div>
|
||||
</div>
|
||||
<span class="text-xs font-medium text-white">{meal.health_score}/10</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if meal.health_score}
|
||||
<div class="mt-2 flex items-center gap-1">
|
||||
<div class="h-1.5 flex-1 overflow-hidden rounded-full bg-white/20">
|
||||
<div
|
||||
class="h-full rounded-full {meal.health_score >= 7
|
||||
? 'bg-green-500'
|
||||
: meal.health_score >= 5
|
||||
? 'bg-yellow-500'
|
||||
: 'bg-red-500'}"
|
||||
style="width: {meal.health_score * 10}%"
|
||||
></div>
|
||||
</div>
|
||||
<span class="text-xs font-medium text-white">{meal.health_score}/10</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Analysis Status Badge -->
|
||||
{#if meal.analysis_status === 'pending'}
|
||||
<div class="absolute right-2 top-2 flex items-center gap-1 rounded-full bg-yellow-500 px-2 py-1 text-xs font-medium text-white">
|
||||
<div class="h-2 w-2 animate-pulse rounded-full bg-white"></div>
|
||||
Analysiert...
|
||||
</div>
|
||||
{:else if meal.analysis_status === 'failed'}
|
||||
<div class="absolute right-2 top-2 rounded-full bg-red-500 px-2 py-1 text-xs font-medium text-white">
|
||||
Fehler
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Analysis Status Badge -->
|
||||
{#if meal.analysis_status === 'pending'}
|
||||
<div
|
||||
class="absolute right-2 top-2 flex items-center gap-1 rounded-full bg-yellow-500 px-2 py-1 text-xs font-medium text-white"
|
||||
>
|
||||
<div class="h-2 w-2 animate-pulse rounded-full bg-white"></div>
|
||||
Analysiert...
|
||||
</div>
|
||||
{:else if meal.analysis_status === 'failed'}
|
||||
<div
|
||||
class="absolute right-2 top-2 rounded-full bg-red-500 px-2 py-1 text-xs font-medium text-white"
|
||||
>
|
||||
Fehler
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -1,164 +1,173 @@
|
|||
<script lang="ts">
|
||||
import type { Meal, MealType } from '$lib/types/meal';
|
||||
import { mealsStore } from '$lib/stores/meals.svelte';
|
||||
import type { Meal, MealType } from '$lib/types/meal';
|
||||
import { mealsStore } from '$lib/stores/meals.svelte';
|
||||
|
||||
interface Props {
|
||||
meal: Meal;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
interface Props {
|
||||
meal: Meal;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { meal, isOpen, onClose }: Props = $props();
|
||||
let { meal, isOpen, onClose }: Props = $props();
|
||||
|
||||
// Form state - initialized from meal
|
||||
let mealType = $state<MealType>(meal.meal_type);
|
||||
let userNotes = $state(meal.user_notes || '');
|
||||
let userRating = $state(meal.user_rating || 0);
|
||||
let isSaving = $state(false);
|
||||
// Form state - initialized from meal
|
||||
let mealType = $state<MealType>(meal.meal_type);
|
||||
let userNotes = $state(meal.user_notes || '');
|
||||
let userRating = $state(meal.user_rating || 0);
|
||||
let isSaving = $state(false);
|
||||
|
||||
// Reset form when meal changes
|
||||
$effect(() => {
|
||||
mealType = meal.meal_type;
|
||||
userNotes = meal.user_notes || '';
|
||||
userRating = meal.user_rating || 0;
|
||||
});
|
||||
// Reset form when meal changes
|
||||
$effect(() => {
|
||||
mealType = meal.meal_type;
|
||||
userNotes = meal.user_notes || '';
|
||||
userRating = meal.user_rating || 0;
|
||||
});
|
||||
|
||||
const mealTypes: { value: MealType; label: string }[] = [
|
||||
{ value: 'breakfast', label: 'Frühstück' },
|
||||
{ value: 'lunch', label: 'Mittagessen' },
|
||||
{ value: 'dinner', label: 'Abendessen' },
|
||||
{ value: 'snack', label: 'Snack' }
|
||||
];
|
||||
const mealTypes: { value: MealType; label: string }[] = [
|
||||
{ value: 'breakfast', label: 'Frühstück' },
|
||||
{ value: 'lunch', label: 'Mittagessen' },
|
||||
{ value: 'dinner', label: 'Abendessen' },
|
||||
{ value: 'snack', label: 'Snack' },
|
||||
];
|
||||
|
||||
async function handleSave() {
|
||||
isSaving = true;
|
||||
try {
|
||||
await mealsStore.updateMeal(meal.id, {
|
||||
meal_type: mealType,
|
||||
user_notes: userNotes || undefined,
|
||||
user_rating: userRating || undefined
|
||||
});
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error('Failed to save meal:', err);
|
||||
} finally {
|
||||
isSaving = false;
|
||||
}
|
||||
}
|
||||
async function handleSave() {
|
||||
isSaving = true;
|
||||
try {
|
||||
await mealsStore.updateMeal(meal.id, {
|
||||
meal_type: mealType,
|
||||
user_notes: userNotes || undefined,
|
||||
user_rating: userRating || undefined,
|
||||
});
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error('Failed to save meal:', err);
|
||||
} finally {
|
||||
isSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleRatingClick(rating: number) {
|
||||
userRating = userRating === rating ? 0 : rating;
|
||||
}
|
||||
function handleRatingClick(rating: number) {
|
||||
userRating = userRating === rating ? 0 : rating;
|
||||
}
|
||||
|
||||
function handleBackdropClick(e: MouseEvent) {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
function handleBackdropClick(e: MouseEvent) {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
{#if isOpen}
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||
onclick={handleBackdropClick}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<!-- Modal -->
|
||||
<div class="w-full max-w-md rounded-2xl bg-white p-6 shadow-xl dark:bg-gray-800">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h2 class="text-xl font-bold text-gray-900 dark:text-white">Mahlzeit bearbeiten</h2>
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="rounded-lg p-2 text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
aria-label="Schließen"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||
onclick={handleBackdropClick}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<!-- Modal -->
|
||||
<div class="w-full max-w-md rounded-2xl bg-white p-6 shadow-xl dark:bg-gray-800">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h2 class="text-xl font-bold text-gray-900 dark:text-white">Mahlzeit bearbeiten</h2>
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="rounded-lg p-2 text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
aria-label="Schließen"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- Meal Type -->
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Art der Mahlzeit
|
||||
</label>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
{#each mealTypes as type}
|
||||
<button
|
||||
onclick={() => (mealType = type.value)}
|
||||
class="rounded-xl px-4 py-2 text-sm font-medium transition-colors {mealType === type.value
|
||||
? 'bg-green-500 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'}"
|
||||
>
|
||||
{type.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<!-- Meal Type -->
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Art der Mahlzeit
|
||||
</label>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
{#each mealTypes as type}
|
||||
<button
|
||||
onclick={() => (mealType = type.value)}
|
||||
class="rounded-xl px-4 py-2 text-sm font-medium transition-colors {mealType ===
|
||||
type.value
|
||||
? 'bg-green-500 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'}"
|
||||
>
|
||||
{type.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Rating -->
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Bewertung
|
||||
</label>
|
||||
<div class="flex gap-1">
|
||||
{#each [1, 2, 3, 4, 5] as star}
|
||||
<button
|
||||
onclick={() => handleRatingClick(star)}
|
||||
class="text-2xl transition-transform hover:scale-110 {star <= userRating
|
||||
? 'text-yellow-400'
|
||||
: 'text-gray-300 dark:text-gray-600'}"
|
||||
aria-label="{star} Stern{star > 1 ? 'e' : ''}"
|
||||
>
|
||||
★
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Rating -->
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Bewertung
|
||||
</label>
|
||||
<div class="flex gap-1">
|
||||
{#each [1, 2, 3, 4, 5] as star}
|
||||
<button
|
||||
onclick={() => handleRatingClick(star)}
|
||||
class="text-2xl transition-transform hover:scale-110 {star <= userRating
|
||||
? 'text-yellow-400'
|
||||
: 'text-gray-300 dark:text-gray-600'}"
|
||||
aria-label="{star} Stern{star > 1 ? 'e' : ''}"
|
||||
>
|
||||
★
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notes -->
|
||||
<div>
|
||||
<label for="notes" class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Notizen
|
||||
</label>
|
||||
<textarea
|
||||
id="notes"
|
||||
bind:value={userNotes}
|
||||
rows="3"
|
||||
placeholder="Notizen zu dieser Mahlzeit..."
|
||||
class="w-full rounded-xl border border-gray-300 bg-white px-4 py-3 text-gray-900 focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/20 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Notes -->
|
||||
<div>
|
||||
<label
|
||||
for="notes"
|
||||
class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
Notizen
|
||||
</label>
|
||||
<textarea
|
||||
id="notes"
|
||||
bind:value={userNotes}
|
||||
rows="3"
|
||||
placeholder="Notizen zu dieser Mahlzeit..."
|
||||
class="w-full rounded-xl border border-gray-300 bg-white px-4 py-3 text-gray-900 focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/20 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="mt-6 flex gap-3">
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="flex-1 rounded-xl border-2 border-gray-300 py-3 font-semibold text-gray-700 transition-colors hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onclick={handleSave}
|
||||
disabled={isSaving}
|
||||
class="flex-1 rounded-xl bg-gradient-to-r from-green-500 to-emerald-600 py-3 font-semibold text-white shadow-lg transition-all hover:from-green-600 hover:to-emerald-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{isSaving ? 'Speichern...' : 'Speichern'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Actions -->
|
||||
<div class="mt-6 flex gap-3">
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="flex-1 rounded-xl border-2 border-gray-300 py-3 font-semibold text-gray-700 transition-colors hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onclick={handleSave}
|
||||
disabled={isSaving}
|
||||
class="flex-1 rounded-xl bg-gradient-to-r from-green-500 to-emerald-600 py-3 font-semibold text-white shadow-lg transition-all hover:from-green-600 hover:to-emerald-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{isSaving ? 'Speichern...' : 'Speichern'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -1,27 +1,25 @@
|
|||
<script lang="ts">
|
||||
import type { Meal } from '$lib/types/meal';
|
||||
import MealCard from './MealCard.svelte';
|
||||
import type { Meal } from '$lib/types/meal';
|
||||
import MealCard from './MealCard.svelte';
|
||||
|
||||
interface Props {
|
||||
meals: Meal[];
|
||||
onMealClick: (meal: Meal) => void;
|
||||
}
|
||||
interface Props {
|
||||
meals: Meal[];
|
||||
onMealClick: (meal: Meal) => void;
|
||||
}
|
||||
|
||||
let { meals, onMealClick }: Props = $props();
|
||||
let { meals, onMealClick }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if meals.length === 0}
|
||||
<div class="flex h-64 flex-col items-center justify-center text-center">
|
||||
<div class="mb-4 text-6xl">🥗</div>
|
||||
<h2 class="mb-2 text-xl font-semibold text-gray-900 dark:text-white">Keine Mahlzeiten</h2>
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
Erfasse deine erste Mahlzeit mit einem Foto
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex h-64 flex-col items-center justify-center text-center">
|
||||
<div class="mb-4 text-6xl">🥗</div>
|
||||
<h2 class="mb-2 text-xl font-semibold text-gray-900 dark:text-white">Keine Mahlzeiten</h2>
|
||||
<p class="text-gray-600 dark:text-gray-400">Erfasse deine erste Mahlzeit mit einem Foto</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{#each meals as meal (meal.id)}
|
||||
<MealCard {meal} onclick={() => onMealClick(meal)} />
|
||||
{/each}
|
||||
</div>
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{#each meals as meal (meal.id)}
|
||||
<MealCard {meal} onclick={() => onMealClick(meal)} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue