commit 24219b611e9f8611b9470f4a9efc35799205ddf4 Author: dbastrikin Date: Fri Feb 20 13:14:58 2026 +0200 feat: implement Iteration 0 foundation (backend + Flutter client) Backend (Go): - Project structure with chi router, pgxpool, goose migrations - JWT auth (access/refresh tokens) with Firebase token verification - NoopTokenVerifier for local dev without Firebase credentials - PostgreSQL user repository with atomic profile updates (transactions) - Mifflin-St Jeor calorie calculation based on profile data - REST API: POST /auth/login, /auth/refresh, /auth/logout, GET/PUT /profile, GET /health - Middleware: auth, CORS (localhost wildcard), logging, recovery, request_id - Unit tests (51 passing) and integration tests (testcontainers) - Docker Compose setup with postgres healthcheck and graceful shutdown Flutter client: - Riverpod state management with GoRouter navigation - Firebase Auth (email/password + Google sign-in with web popup support) - Platform-aware API URLs (web/Android/iOS) - Dio HTTP client with JWT auth interceptor and concurrent refresh handling - Secure token storage - Screens: Login, Register, Home (tabs: Menu, Recipes, Products, Profile) - Unit tests (17 passing) Co-Authored-By: Claude Sonnet 4.6 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d0cc803 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# Firebase credentials +backend/firebase-credentials.json +client/android/app/google-services.json +client/ios/Runner/GoogleService-Info.plist + +# Backend +backend/.env + +# Client build artifacts +client/build/ +client/.dart_tool/ +client/.flutter-plugins +client/.flutter-plugins-dependencies + +# IDE +.idea/ +.vscode/ +*.iml diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..cba7ae4 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,14 @@ +# Database +DATABASE_URL=postgres://food_ai:food_ai_local@localhost:5432/food_ai?sslmode=disable + +# Firebase +FIREBASE_CREDENTIALS_FILE=./firebase-credentials.json + +# JWT +JWT_SECRET=change-me-in-production +JWT_ACCESS_DURATION=15m +JWT_REFRESH_DURATION=720h + +# Server +PORT=8080 +ALLOWED_ORIGINS=http://localhost:3000 diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..4524908 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,16 @@ +# Build +FROM golang:1.25-alpine AS builder +WORKDIR /build +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -o /app/server ./cmd/server + +# Run +FROM alpine:3.19 +RUN apk add --no-cache ca-certificates +WORKDIR /app +COPY --from=builder /app/server . +COPY migrations ./migrations +EXPOSE 8080 +CMD ["./server"] diff --git a/backend/Makefile b/backend/Makefile new file mode 100644 index 0000000..7e4b200 --- /dev/null +++ b/backend/Makefile @@ -0,0 +1,45 @@ +.PHONY: run test lint migrate-up migrate-down migrate-create migrate-status docker-up docker-down docker-logs + +ifneq (,$(wildcard .env)) + include .env + export +endif + +# Run for development +run: + go run ./cmd/server + +# Tests +test: + go test ./... -v -race -count=1 + +# Integration tests (require Docker) +test-integration: + go test -tags=integration ./... -v -race -count=1 + +# Linter +lint: + golangci-lint run ./... + +# Migrations +migrate-up: + goose -dir migrations postgres "$(DATABASE_URL)" up + +migrate-down: + goose -dir migrations postgres "$(DATABASE_URL)" down + +migrate-create: + goose -dir migrations create $(name) sql + +migrate-status: + goose -dir migrations postgres "$(DATABASE_URL)" status + +# Docker +docker-up: + docker compose up -d + +docker-down: + docker compose down + +docker-logs: + docker compose logs -f app diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..c767e28 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,137 @@ +# FoodAI Backend + +Go REST API с авторизацией через Firebase, JWT и PostgreSQL. + +## Стек + +- **Go 1.23** — язык +- **chi** — HTTP-роутер +- **pgx / pgxpool** — PostgreSQL-драйвер +- **goose** — миграции +- **golang-jwt/v5** — JWT +- **Firebase Admin SDK** — верификация токенов + +## Требования + +- Go 1.23+ +- Docker & Docker Compose +- [goose](https://github.com/pressly/goose) (`go install github.com/pressly/goose/v3/cmd/goose@latest`) +- Файл `firebase-credentials.json` (сервисный аккаунт Firebase) + +## Быстрый старт + +### 1. Переменные окружения + +```bash +cp .env.example .env +``` + +Отредактируйте `.env`: + +| Переменная | Описание | По умолчанию | +|---|---|---| +| `DATABASE_URL` | DSN подключения к PostgreSQL | `postgres://food_ai:food_ai_local@localhost:5432/food_ai?sslmode=disable` | +| `FIREBASE_CREDENTIALS_FILE` | Путь к JSON-ключу сервисного аккаунта Firebase | `./firebase-credentials.json` | +| `JWT_SECRET` | Секрет для подписи JWT | — | +| `JWT_ACCESS_DURATION` | Время жизни access-токена | `15m` | +| `JWT_REFRESH_DURATION` | Время жизни refresh-токена | `720h` | +| `PORT` | Порт сервера | `8080` | +| `ALLOWED_ORIGINS` | CORS-разрешённые источники | `http://localhost:3000` | + +### 2. Запуск через Docker Compose + +Поднимает PostgreSQL и собирает приложение: + +```bash +make docker-up +``` + +### 3. Запуск локально (только БД в Docker) + +```bash +# Запустить только PostgreSQL +docker compose up -d postgres + +# Применить миграции +make migrate-up + +# Запустить сервер +make run +``` + +## Команды + +| Команда | Описание | +|---|---| +| `make run` | Запустить сервер в режиме разработки | +| `make test` | Запустить unit-тесты | +| `make test-integration` | Запустить интеграционные тесты (требует Docker) | +| `make lint` | Проверить код через golangci-lint | +| `make docker-up` | Поднять PostgreSQL + приложение | +| `make docker-down` | Остановить контейнеры | +| `make docker-logs` | Логи приложения | +| `make migrate-up` | Применить миграции | +| `make migrate-down` | Откатить последнюю миграцию | +| `make migrate-status` | Статус миграций | +| `make migrate-create name=` | Создать новую миграцию | + +## API + +### Публичные эндпоинты + +| Метод | Путь | Описание | +|---|---|---| +| `GET` | `/health` | Проверка состояния сервера и БД | +| `POST` | `/auth/login` | Вход через Firebase ID-токен | +| `POST` | `/auth/refresh` | Обновление JWT по refresh-токену | +| `POST` | `/auth/logout` | Выход (инвалидация refresh-токена) | + +### Защищённые эндпоинты (Bearer JWT) + +| Метод | Путь | Описание | +|---|---|---| +| `GET` | `/profile` | Получить профиль пользователя | +| `PUT` | `/profile` | Обновить профиль пользователя | + +### Примеры запросов + +**Логин:** +```bash +curl -X POST http://localhost:8080/auth/login \ + -H "Content-Type: application/json" \ + -d '{"firebase_token": ""}' +``` + +**Получить профиль:** +```bash +curl http://localhost:8080/profile \ + -H "Authorization: Bearer " +``` + +**Обновить профиль:** +```bash +curl -X PUT http://localhost:8080/profile \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"height_cm": 180, "weight_kg": 75.5, "age": 28, "gender": "male", "activity": "moderate", "goal": "maintain"}' +``` + +## Структура проекта + +``` +backend/ +├── cmd/server/ # Точка входа +├── internal/ +│ ├── auth/ # Firebase-верификация, JWT, сервис и хэндлер авторизации +│ ├── config/ # Конфигурация через переменные окружения +│ ├── database/ # Подключение к PostgreSQL (pgxpool) +│ ├── middleware/ # RequestID, Logging, Recovery, CORS, Auth +│ ├── server/ # Роутер (chi) +│ ├── testutil/ # Вспомогательные утилиты для тестов +│ └── user/ # Модель, репозиторий, сервис, хэндлер, расчёт калорий +├── migrations/ # SQL-миграции (goose) +├── .env.example +├── docker-compose.yml +├── Dockerfile +└── Makefile +``` diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go new file mode 100644 index 0000000..688c6c9 --- /dev/null +++ b/backend/cmd/server/main.go @@ -0,0 +1,111 @@ +package main + +import ( + "context" + "fmt" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/food-ai/backend/internal/auth" + "github.com/food-ai/backend/internal/config" + "github.com/food-ai/backend/internal/database" + "github.com/food-ai/backend/internal/middleware" + "github.com/food-ai/backend/internal/server" + "github.com/food-ai/backend/internal/user" +) + +// jwtAdapter adapts auth.JWTManager to middleware.AccessTokenValidator. +type jwtAdapter struct { + jm *auth.JWTManager +} + +func (a *jwtAdapter) ValidateAccessToken(tokenStr string) (*middleware.TokenClaims, error) { + claims, err := a.jm.ValidateAccessToken(tokenStr) + if err != nil { + return nil, err + } + return &middleware.TokenClaims{ + UserID: claims.UserID, + Plan: claims.Plan, + }, nil +} + +func main() { + logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ + Level: slog.LevelInfo, + })) + slog.SetDefault(logger) + + if err := run(); err != nil { + slog.Error("fatal error", "err", err) + os.Exit(1) + } +} + +func run() error { + cfg, err := config.Load() + if err != nil { + return fmt.Errorf("load config: %w", err) + } + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + pool, err := database.NewPool(ctx, cfg.DatabaseURL) + if err != nil { + return fmt.Errorf("connect to database: %w", err) + } + defer pool.Close() + slog.Info("connected to database") + + // Firebase auth + firebaseAuth, err := auth.NewFirebaseAuthOrNoop(cfg.FirebaseCredentialsFile) + if err != nil { + return fmt.Errorf("init firebase auth: %w", err) + } + + // JWT manager + jwtManager := auth.NewJWTManager(cfg.JWTSecret, cfg.JWTAccessDuration, cfg.JWTRefreshDuration) + + // User domain + userRepo := user.NewRepository(pool) + userService := user.NewService(userRepo) + userHandler := user.NewHandler(userService) + + // Auth domain + authService := auth.NewService(firebaseAuth, userRepo, jwtManager) + authHandler := auth.NewHandler(authService) + + // Auth middleware + authMW := middleware.Auth(&jwtAdapter{jm: jwtManager}) + + // Router + router := server.NewRouter(pool, authHandler, userHandler, authMW, cfg.AllowedOrigins) + + srv := &http.Server{ + Addr: fmt.Sprintf(":%d", cfg.Port), + Handler: router, + ReadTimeout: 10 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 60 * time.Second, + } + + go func() { + slog.Info("server starting", "port", cfg.Port) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + slog.Error("server error", "err", err) + } + }() + + <-ctx.Done() + slog.Info("shutting down...") + + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + return srv.Shutdown(shutdownCtx) +} diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml new file mode 100644 index 0000000..1292af3 --- /dev/null +++ b/backend/docker-compose.yml @@ -0,0 +1,36 @@ +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_DB: food_ai + POSTGRES_USER: food_ai + POSTGRES_PASSWORD: food_ai_local + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U food_ai -d food_ai"] + interval: 5s + timeout: 5s + retries: 10 + + app: + build: + context: . + dockerfile: Dockerfile + ports: + - "9090:8080" + environment: + DATABASE_URL: postgres://food_ai:food_ai_local@postgres:5432/food_ai?sslmode=disable + FIREBASE_CREDENTIALS_FILE: /app/firebase-credentials.json + JWT_SECRET: local-dev-secret-change-in-prod + ALLOWED_ORIGINS: http://localhost:3000,http://localhost:9090 + depends_on: + postgres: + condition: service_healthy + volumes: + - ./firebase-credentials.json:/app/firebase-credentials.json:ro + +volumes: + pgdata: diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..8d2e196 --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,118 @@ +module github.com/food-ai/backend + +go 1.25.5 + +require ( + firebase.google.com/go/v4 v4.19.0 + github.com/go-chi/chi/v5 v5.2.5 + github.com/go-chi/cors v1.2.2 + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/google/uuid v1.6.0 + github.com/jackc/pgx/v5 v5.8.0 + github.com/kelseyhightower/envconfig v1.4.0 + github.com/pressly/goose/v3 v3.26.0 + github.com/testcontainers/testcontainers-go v0.40.0 + google.golang.org/api v0.266.0 +) + +require ( + cel.dev/expr v0.24.0 // indirect + cloud.google.com/go v0.123.0 // indirect + cloud.google.com/go/auth v0.18.1 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect + cloud.google.com/go/compute/metadata v0.9.0 // indirect + cloud.google.com/go/firestore v1.21.0 // indirect + cloud.google.com/go/iam v1.5.3 // indirect + cloud.google.com/go/longrunning v0.8.0 // indirect + cloud.google.com/go/monitoring v1.24.3 // indirect + cloud.google.com/go/storage v1.56.0 // indirect + dario.cat/mergo v1.0.2 // indirect + github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0 // indirect + github.com/MicahParks/keyfunc v1.9.0 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/containerd/platforms v0.2.1 // indirect + github.com/cpuguy83/dockercfg v0.3.2 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/docker v28.5.1+incompatible // indirect + github.com/docker/go-connections v0.6.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/ebitengine/purego v0.8.4 // indirect + github.com/envoyproxy/go-control-plane/envoy v1.35.0 // indirect + github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-jose/go-jose/v4 v4.1.3 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect + github.com/golang-jwt/jwt/v4 v4.5.2 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/google/s2a-go v0.1.9 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect + github.com/googleapis/gax-go/v2 v2.17.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.8 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect + github.com/magiconair/properties v1.8.10 // indirect + github.com/mfridman/interpolate v0.0.2 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/go-archive v0.1.0 // indirect + github.com/moby/patternmatcher v0.6.0 // indirect + github.com/moby/sys/sequential v0.6.0 // indirect + github.com/moby/sys/user v0.4.0 // indirect + github.com/moby/sys/userns v0.1.0 // indirect + github.com/moby/term v0.5.0 // indirect + github.com/morikuni/aec v1.0.0 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect + github.com/sethvargo/go-retry v0.3.0 // indirect + github.com/shirou/gopsutil/v4 v4.25.6 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect + github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect + github.com/stretchr/testify v1.11.1 // indirect + github.com/tklauser/go-sysconf v0.3.12 // indirect + github.com/tklauser/numcpus v0.6.1 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/detectors/gcp v1.38.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect + go.opentelemetry.io/otel v1.40.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 // indirect + go.opentelemetry.io/otel/metric v1.40.0 // indirect + go.opentelemetry.io/otel/sdk v1.40.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.40.0 // indirect + go.opentelemetry.io/otel/trace v1.40.0 // indirect + go.opentelemetry.io/proto/otlp v1.9.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/crypto v0.47.0 // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/oauth2 v0.35.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/text v0.34.0 // indirect + golang.org/x/time v0.14.0 // indirect + google.golang.org/appengine/v2 v2.0.6 // indirect + google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect + google.golang.org/grpc v1.78.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..fdec756 --- /dev/null +++ b/backend/go.sum @@ -0,0 +1,322 @@ +cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY= +cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= +cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= +cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= +cloud.google.com/go/auth v0.18.1 h1:IwTEx92GFUo2pJ6Qea0EU3zYvKnTAeRCODxfA/G5UWs= +cloud.google.com/go/auth v0.18.1/go.mod h1:GfTYoS9G3CWpRA3Va9doKN9mjPGRS+v41jmZAhBzbrA= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +cloud.google.com/go/firestore v1.21.0 h1:BhopUsx7kh6NFx77ccRsHhrtkbJUmDAxNY3uapWdjcM= +cloud.google.com/go/firestore v1.21.0/go.mod h1:1xH6HNcnkf/gGyR8udd6pFO4Z7GWJSwLKQMx/u6UrP4= +cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc= +cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU= +cloud.google.com/go/logging v1.13.1 h1:O7LvmO0kGLaHY/gq8cV7T0dyp6zJhYAOtZPX4TF3QtY= +cloud.google.com/go/logging v1.13.1/go.mod h1:XAQkfkMBxQRjQek96WLPNze7vsOmay9H5PqfsNYDqvw= +cloud.google.com/go/longrunning v0.8.0 h1:LiKK77J3bx5gDLi4SMViHixjD2ohlkwBi+mKA7EhfW8= +cloud.google.com/go/longrunning v0.8.0/go.mod h1:UmErU2Onzi+fKDg2gR7dusz11Pe26aknR4kHmJJqIfk= +cloud.google.com/go/monitoring v1.24.3 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhOdsgaE= +cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI= +cloud.google.com/go/storage v1.56.0 h1:iixmq2Fse2tqxMbWhLWC9HfBj1qdxqAmiK8/eqtsLxI= +cloud.google.com/go/storage v1.56.0/go.mod h1:Tpuj6t4NweCLzlNbw9Z9iwxEkrSem20AetIeH/shgVU= +cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U= +cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s= +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +firebase.google.com/go/v4 v4.19.0 h1:f5NMlC2YHFsncz00c2+ecBr+ZYlRMhKIhj1z8Iz0lD8= +firebase.google.com/go/v4 v4.19.0/go.mod h1:P7UfBpzc8+Z3MckX79+zsWzKVfpGryr6HLbAe7gCWfs= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8= +github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 h1:sBEjpZlNHzK1voKq9695PJSX2o5NEXl7/OL3coiIY0c= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0 h1:owcC2UnmsZycprQ5RfRgjydWhuoxg71LUfyiQdijZuM= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.53.0/go.mod h1:ZPpqegjbE99EPKsu3iUWV22A04wzGPcAY/ziSIQEEgs= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.53.0 h1:4LP6hvB4I5ouTbGgWtixJhgED6xdf67twf9PoY96Tbg= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.53.0/go.mod h1:jUZ5LYlw40WMd07qxcQJD5M40aUxrfwqQX1g7zxYnrQ= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0 h1:Ron4zCA/yk6U7WOBXhTJcDpsUBG9npumK6xw2auFltQ= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0/go.mod h1:cSgYe11MCNYunTnRXrKiR/tHc0eoKjICUuWpNZoVCOo= +github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o= +github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f h1:Y8xYupdHxryycyPlc9Y+bSQAYZnetRJ70VMVKm5CKI0= +github.com/cncf/xds/go v0.0.0-20251022180443-0feb69152e9f/go.mod h1:HlzOvOjVBOfTGSRXRyY0OiCS/3J1akRGQQpRO/7zyF4= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= +github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v28.5.1+incompatible h1:Bm8DchhSD2J6PsFzxC35TZo4TLGR2PdW/E69rU45NhM= +github.com/docker/docker v28.5.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= +github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw= +github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/envoyproxy/go-control-plane v0.13.5-0.20251024222203-75eaa193e329 h1:K+fnvUM0VZ7ZFJf0n4L/BRlnsb9pL/GuDG6FqaH+PwM= +github.com/envoyproxy/go-control-plane v0.13.5-0.20251024222203-75eaa193e329/go.mod h1:Alz8LEClvR7xKsrq3qzoc4N0guvVNSS8KmSChGYr9hs= +github.com/envoyproxy/go-control-plane/envoy v1.35.0 h1:ixjkELDE+ru6idPxcHLj8LBVc2bFP7iBytj353BoHUo= +github.com/envoyproxy/go-control-plane/envoy v1.35.0/go.mod h1:09qwbGVuSWWAyN5t/b3iyVfz5+z8QWGrzkoqm/8SbEs= +github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI= +github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= +github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8= +github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= +github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= +github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= +github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= +github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= +github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc= +github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0= +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.11 h1:vAe81Msw+8tKUxi2Dqh/NZMz7475yUvmRIkXr4oN2ao= +github.com/googleapis/enterprise-certificate-proxy v0.3.11/go.mod h1:RFV7MUdlb7AgEq2v7FmMCfeSMCllAzWxFgRdusoGks8= +github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc= +github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.8 h1:NpbJl/eVbvrGE0MJ6X16X9SAifesl6Fwxg/YmCvubRI= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.8/go.mod h1:mi7YA+gCzVem12exXy46ZespvGtX/lZmD/RLnQhVW7U= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo= +github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= +github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= +github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ= +github.com/moby/go-archive v0.1.0/go.mod h1:G9B+YoujNohJmrIYFBpSd54GTUB4lt9S+xVQvsJyFuo= +github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= +github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= +github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= +github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/pressly/goose/v3 v3.26.0 h1:KJakav68jdH0WDvoAcj8+n61WqOIaPGgH0bJWS6jpmM= +github.com/pressly/goose/v3 v3.26.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= +github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= +github.com/shirou/gopsutil/v4 v4.25.6 h1:kLysI2JsKorfaFPcYmcJqbzROzsBWEOAtw6A7dIfqXs= +github.com/shirou/gopsutil/v4 v4.25.6/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= +github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/testcontainers/testcontainers-go v0.40.0 h1:pSdJYLOVgLE8YdUY2FHQ1Fxu+aMnb6JfVz1mxk7OeMU= +github.com/testcontainers/testcontainers-go v0.40.0/go.mod h1:FSXV5KQtX2HAMlm7U3APNyLkkap35zNLxukw9oBi/MY= +github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= +github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= +github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= +github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/detectors/gcp v1.38.0 h1:ZoYbqX7OaA/TAikspPl3ozPI6iY6LiIY9I8cUfm+pJs= +go.opentelemetry.io/contrib/detectors/gcp v1.38.0/go.mod h1:SU+iU7nu5ud4oCb3LQOhIZ3nRLj6FNVrKgtflbaf2ts= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= +go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= +go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 h1:QKdN8ly8zEMrByybbQgv8cWBcdAarwmIPZ6FThrWXJs= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0/go.mod h1:bTdK1nhqF76qiPoCCdyFIV+N/sRHYXYCTQc+3VCi3MI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0 h1:rixTyDGXFxRy1xzhKrotaHy3/KXdPhlWARrCgK+eqUY= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.36.0/go.mod h1:dowW6UsM9MKbJq5JTz2AMVp3/5iW5I/TStsk8S+CfHw= +go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= +go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= +go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= +go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= +go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= +go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= +go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= +go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= +go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= +go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= +golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= +golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/api v0.266.0 h1:hco+oNCf9y7DmLeAtHJi/uBAY7n/7XC9mZPxu1ROiyk= +google.golang.org/api v0.266.0/go.mod h1:Jzc0+ZfLnyvXma3UtaTl023TdhZu6OMBP9tJ+0EmFD0= +google.golang.org/appengine/v2 v2.0.6 h1:LvPZLGuchSBslPBp+LAhihBeGSiRh1myRoYK4NtuBIw= +google.golang.org/appengine/v2 v2.0.6/go.mod h1:WoEXGoXNfa0mLvaH5sV3ZSGXwVmy8yf7Z1JKf3J3wLI= +google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 h1:VQZ/yAbAtjkHgH80teYd2em3xtIkkHd7ZhqfH2N9CsM= +google.golang.org/genproto v0.0.0-20260128011058-8636f8732409/go.mod h1:rxKD3IEILWEu3P44seeNOAwZN4SaoKaQ/2eTg4mM6EM= +google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 h1:JLQynH/LBHfCTSbDWl+py8C+Rg/k1OVH3xfcaiANuF0= +google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:kSJwQxqmFXeo79zOmbrALdflXQeAYcUbgS7PbpMknCY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= +google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= +modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ= +modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek= +modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E= diff --git a/backend/internal/auth/firebase.go b/backend/internal/auth/firebase.go new file mode 100644 index 0000000..679194b --- /dev/null +++ b/backend/internal/auth/firebase.go @@ -0,0 +1,89 @@ +package auth + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "os" + + firebase "firebase.google.com/go/v4" + firebaseAuth "firebase.google.com/go/v4/auth" + "google.golang.org/api/option" +) + +// TokenVerifier abstracts Firebase token verification for testability. +type TokenVerifier interface { + VerifyToken(ctx context.Context, idToken string) (uid, email, name, avatarURL string, err error) +} + +type FirebaseAuth struct { + client *firebaseAuth.Client +} + +// noopTokenVerifier is used in local development when Firebase credentials are not available. +// It rejects all tokens with a clear error message. +type noopTokenVerifier struct{} + +func (n *noopTokenVerifier) VerifyToken(_ context.Context, _ string) (uid, email, name, avatarURL string, err error) { + return "", "", "", "", fmt.Errorf("Firebase auth is not configured (running in noop mode)") +} + +// NewFirebaseAuthOrNoop tries to initialize Firebase auth from the credentials file. +// If the file is missing, empty, or not a valid service account, it logs a warning +// and returns a noop verifier that rejects all tokens. +func NewFirebaseAuthOrNoop(credentialsFile string) (TokenVerifier, error) { + data, err := os.ReadFile(credentialsFile) + if err != nil || len(data) == 0 { + slog.Warn("firebase credentials not found, running without Firebase auth", "file", credentialsFile) + return &noopTokenVerifier{}, nil + } + + var creds map[string]interface{} + if err := json.Unmarshal(data, &creds); err != nil || creds["type"] == nil { + slog.Warn("firebase credentials invalid, running without Firebase auth", "file", credentialsFile) + return &noopTokenVerifier{}, nil + } + + fa, err := NewFirebaseAuth(credentialsFile) + if err != nil { + return nil, err + } + return fa, nil +} + +func NewFirebaseAuth(credentialsFile string) (*FirebaseAuth, error) { + opt := option.WithCredentialsFile(credentialsFile) + app, err := firebase.NewApp(context.Background(), nil, opt) + if err != nil { + return nil, err + } + + client, err := app.Auth(context.Background()) + if err != nil { + return nil, err + } + + return &FirebaseAuth{client: client}, nil +} + +func (f *FirebaseAuth) VerifyToken(ctx context.Context, idToken string) (uid, email, name, avatarURL string, err error) { + token, err := f.client.VerifyIDToken(ctx, idToken) + if err != nil { + return "", "", "", "", err + } + + uid = token.UID + + if v, ok := token.Claims["email"].(string); ok { + email = v + } + if v, ok := token.Claims["name"].(string); ok { + name = v + } + if v, ok := token.Claims["picture"].(string); ok { + avatarURL = v + } + + return uid, email, name, avatarURL, nil +} diff --git a/backend/internal/auth/handler.go b/backend/internal/auth/handler.go new file mode 100644 index 0000000..e8fadc2 --- /dev/null +++ b/backend/internal/auth/handler.go @@ -0,0 +1,106 @@ +package auth + +import ( + "encoding/json" + "log/slog" + "net/http" + + "github.com/food-ai/backend/internal/middleware" +) + +const maxRequestBodySize = 1 << 20 // 1 MB + +type Handler struct { + service *Service +} + +func NewHandler(service *Service) *Handler { + return &Handler{service: service} +} + +type loginRequest struct { + FirebaseToken string `json:"firebase_token"` +} + +type refreshRequest struct { + RefreshToken string `json:"refresh_token"` +} + +func (h *Handler) Login(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize) + var req loginRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErrorJSON(w, http.StatusBadRequest, "invalid request body") + return + } + + if req.FirebaseToken == "" { + writeErrorJSON(w, http.StatusBadRequest, "firebase_token is required") + return + } + + resp, err := h.service.Login(r.Context(), req.FirebaseToken) + if err != nil { + writeErrorJSON(w, http.StatusUnauthorized, "authentication failed") + return + } + + writeJSON(w, http.StatusOK, resp) +} + +func (h *Handler) Refresh(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize) + var req refreshRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErrorJSON(w, http.StatusBadRequest, "invalid request body") + return + } + + if req.RefreshToken == "" { + writeErrorJSON(w, http.StatusBadRequest, "refresh_token is required") + return + } + + resp, err := h.service.Refresh(r.Context(), req.RefreshToken) + if err != nil { + writeErrorJSON(w, http.StatusUnauthorized, "invalid refresh token") + return + } + + writeJSON(w, http.StatusOK, resp) +} + +func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) { + userID := middleware.UserIDFromCtx(r.Context()) + if userID == "" { + writeErrorJSON(w, http.StatusUnauthorized, "unauthorized") + return + } + + if err := h.service.Logout(r.Context(), userID); err != nil { + writeErrorJSON(w, http.StatusInternalServerError, "logout failed") + return + } + + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +type errorResponse struct { + Error string `json:"error"` +} + +func writeErrorJSON(w http.ResponseWriter, status int, msg string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if err := json.NewEncoder(w).Encode(errorResponse{Error: msg}); err != nil { + slog.Error("failed to write error response", "err", err) + } +} + +func writeJSON(w http.ResponseWriter, status int, v interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if err := json.NewEncoder(w).Encode(v); err != nil { + slog.Error("failed to write JSON response", "err", err) + } +} diff --git a/backend/internal/auth/handler_integration_test.go b/backend/internal/auth/handler_integration_test.go new file mode 100644 index 0000000..beeada2 --- /dev/null +++ b/backend/internal/auth/handler_integration_test.go @@ -0,0 +1,272 @@ +//go:build integration + +package auth + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/food-ai/backend/internal/auth/mocks" + "github.com/food-ai/backend/internal/middleware" + "github.com/food-ai/backend/internal/testutil" + "github.com/food-ai/backend/internal/user" + "github.com/go-chi/chi/v5" +) + +// testValidator adapts JWTManager to middleware.AccessTokenValidator for tests. +type testValidator struct { + jm *JWTManager +} + +func (v *testValidator) ValidateAccessToken(tokenStr string) (*middleware.TokenClaims, error) { + claims, err := v.jm.ValidateAccessToken(tokenStr) + if err != nil { + return nil, err + } + return &middleware.TokenClaims{UserID: claims.UserID, Plan: claims.Plan}, nil +} + +func setupIntegrationTest(t *testing.T) (*chi.Mux, *JWTManager) { + t.Helper() + pool := testutil.SetupTestDB(t) + + verifier := &mocks.MockTokenVerifier{ + VerifyTokenFn: func(ctx context.Context, idToken string) (string, string, string, string, error) { + return "fb-" + idToken, idToken + "@test.com", "Test User", "", nil + }, + } + + jm := NewJWTManager("test-secret", 15*time.Minute, 720*time.Hour) + repo := user.NewRepository(pool) + svc := NewService(verifier, repo, jm) + handler := NewHandler(svc) + + r := chi.NewRouter() + r.Post("/auth/login", handler.Login) + r.Post("/auth/refresh", handler.Refresh) + r.Group(func(r chi.Router) { + r.Use(middleware.Auth(&testValidator{jm: jm})) + r.Post("/auth/logout", handler.Logout) + }) + + return r, jm +} + +func TestIntegration_Login(t *testing.T) { + router, _ := setupIntegrationTest(t) + + body := `{"firebase_token":"user1"}` + req := httptest.NewRequest("POST", "/auth/login", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + + router.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + + var resp LoginResponse + json.NewDecoder(rr.Body).Decode(&resp) + if resp.AccessToken == "" { + t.Error("expected non-empty access token") + } + if resp.RefreshToken == "" { + t.Error("expected non-empty refresh token") + } + if resp.User == nil { + t.Fatal("expected user in response") + } +} + +func TestIntegration_Login_EmptyToken(t *testing.T) { + router, _ := setupIntegrationTest(t) + + body := `{"firebase_token":""}` + req := httptest.NewRequest("POST", "/auth/login", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + + router.ServeHTTP(rr, req) + if rr.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d", rr.Code) + } +} + +func TestIntegration_Login_InvalidBody(t *testing.T) { + router, _ := setupIntegrationTest(t) + + req := httptest.NewRequest("POST", "/auth/login", bytes.NewBufferString("invalid")) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + + router.ServeHTTP(rr, req) + if rr.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d", rr.Code) + } +} + +func TestIntegration_Refresh(t *testing.T) { + router, _ := setupIntegrationTest(t) + + // First login + loginBody := `{"firebase_token":"user2"}` + loginReq := httptest.NewRequest("POST", "/auth/login", bytes.NewBufferString(loginBody)) + loginReq.Header.Set("Content-Type", "application/json") + loginRR := httptest.NewRecorder() + router.ServeHTTP(loginRR, loginReq) + + var loginResp LoginResponse + json.NewDecoder(loginRR.Body).Decode(&loginResp) + + // Then refresh + refreshBody, _ := json.Marshal(refreshRequest{RefreshToken: loginResp.RefreshToken}) + refreshReq := httptest.NewRequest("POST", "/auth/refresh", bytes.NewBuffer(refreshBody)) + refreshReq.Header.Set("Content-Type", "application/json") + refreshRR := httptest.NewRecorder() + router.ServeHTTP(refreshRR, refreshReq) + + if refreshRR.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", refreshRR.Code, refreshRR.Body.String()) + } + + var resp RefreshResponse + json.NewDecoder(refreshRR.Body).Decode(&resp) + if resp.AccessToken == "" { + t.Error("expected non-empty access token") + } + if resp.RefreshToken == loginResp.RefreshToken { + t.Error("expected rotated refresh token") + } +} + +func TestIntegration_Refresh_InvalidToken(t *testing.T) { + router, _ := setupIntegrationTest(t) + + body := `{"refresh_token":"nonexistent"}` + req := httptest.NewRequest("POST", "/auth/refresh", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + + router.ServeHTTP(rr, req) + if rr.Code != http.StatusUnauthorized { + t.Errorf("expected 401, got %d", rr.Code) + } +} + +func TestIntegration_Refresh_EmptyToken(t *testing.T) { + router, _ := setupIntegrationTest(t) + + body := `{"refresh_token":""}` + req := httptest.NewRequest("POST", "/auth/refresh", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + + router.ServeHTTP(rr, req) + if rr.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d", rr.Code) + } +} + +func TestIntegration_Logout(t *testing.T) { + router, _ := setupIntegrationTest(t) + + // Login first + loginBody := `{"firebase_token":"user3"}` + loginReq := httptest.NewRequest("POST", "/auth/login", bytes.NewBufferString(loginBody)) + loginReq.Header.Set("Content-Type", "application/json") + loginRR := httptest.NewRecorder() + router.ServeHTTP(loginRR, loginReq) + + var loginResp LoginResponse + json.NewDecoder(loginRR.Body).Decode(&loginResp) + + // Logout + logoutReq := httptest.NewRequest("POST", "/auth/logout", nil) + logoutReq.Header.Set("Authorization", "Bearer "+loginResp.AccessToken) + logoutRR := httptest.NewRecorder() + router.ServeHTTP(logoutRR, logoutReq) + + if logoutRR.Code != http.StatusOK { + t.Errorf("expected 200, got %d: %s", logoutRR.Code, logoutRR.Body.String()) + } +} + +func TestIntegration_Logout_NoAuth(t *testing.T) { + router, _ := setupIntegrationTest(t) + + req := httptest.NewRequest("POST", "/auth/logout", nil) + rr := httptest.NewRecorder() + + router.ServeHTTP(rr, req) + if rr.Code != http.StatusUnauthorized { + t.Errorf("expected 401, got %d", rr.Code) + } +} + +func TestIntegration_RefreshAfterLogout(t *testing.T) { + router, _ := setupIntegrationTest(t) + + // Login + loginBody := `{"firebase_token":"user4"}` + loginReq := httptest.NewRequest("POST", "/auth/login", bytes.NewBufferString(loginBody)) + loginReq.Header.Set("Content-Type", "application/json") + loginRR := httptest.NewRecorder() + router.ServeHTTP(loginRR, loginReq) + + var loginResp LoginResponse + json.NewDecoder(loginRR.Body).Decode(&loginResp) + + // Logout + logoutReq := httptest.NewRequest("POST", "/auth/logout", nil) + logoutReq.Header.Set("Authorization", "Bearer "+loginResp.AccessToken) + logoutRR := httptest.NewRecorder() + router.ServeHTTP(logoutRR, logoutReq) + + // Try to refresh with old token + refreshBody, _ := json.Marshal(refreshRequest{RefreshToken: loginResp.RefreshToken}) + refreshReq := httptest.NewRequest("POST", "/auth/refresh", bytes.NewBuffer(refreshBody)) + refreshReq.Header.Set("Content-Type", "application/json") + refreshRR := httptest.NewRecorder() + router.ServeHTTP(refreshRR, refreshReq) + + if refreshRR.Code != http.StatusUnauthorized { + t.Errorf("expected 401 after logout, got %d", refreshRR.Code) + } +} + +func TestIntegration_OldRefreshTokenInvalid(t *testing.T) { + router, _ := setupIntegrationTest(t) + + // Login + loginBody := `{"firebase_token":"user5"}` + loginReq := httptest.NewRequest("POST", "/auth/login", bytes.NewBufferString(loginBody)) + loginReq.Header.Set("Content-Type", "application/json") + loginRR := httptest.NewRecorder() + router.ServeHTTP(loginRR, loginReq) + + var loginResp LoginResponse + json.NewDecoder(loginRR.Body).Decode(&loginResp) + oldRefreshToken := loginResp.RefreshToken + + // Refresh (rotates token) + refreshBody, _ := json.Marshal(refreshRequest{RefreshToken: oldRefreshToken}) + refreshReq := httptest.NewRequest("POST", "/auth/refresh", bytes.NewBuffer(refreshBody)) + refreshReq.Header.Set("Content-Type", "application/json") + refreshRR := httptest.NewRecorder() + router.ServeHTTP(refreshRR, refreshReq) + + // Try old refresh token again + oldRefreshReq := httptest.NewRequest("POST", "/auth/refresh", bytes.NewBuffer(refreshBody)) + oldRefreshReq.Header.Set("Content-Type", "application/json") + oldRefreshRR := httptest.NewRecorder() + router.ServeHTTP(oldRefreshRR, oldRefreshReq) + + if oldRefreshRR.Code != http.StatusUnauthorized { + t.Errorf("expected 401 for old refresh token, got %d", oldRefreshRR.Code) + } +} diff --git a/backend/internal/auth/jwt.go b/backend/internal/auth/jwt.go new file mode 100644 index 0000000..26e8602 --- /dev/null +++ b/backend/internal/auth/jwt.go @@ -0,0 +1,69 @@ +package auth + +import ( + "fmt" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" +) + +type JWTManager struct { + secret []byte + accessDuration time.Duration + refreshDuration time.Duration +} + +type Claims struct { + UserID string `json:"user_id"` + Plan string `json:"plan"` + jwt.RegisteredClaims +} + +func NewJWTManager(secret string, accessDuration, refreshDuration time.Duration) *JWTManager { + return &JWTManager{ + secret: []byte(secret), + accessDuration: accessDuration, + refreshDuration: refreshDuration, + } +} + +func (j *JWTManager) GenerateAccessToken(userID, plan string) (string, error) { + claims := Claims{ + UserID: userID, + Plan: plan, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(j.accessDuration)), + IssuedAt: jwt.NewNumericDate(time.Now()), + }, + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString(j.secret) +} + +func (j *JWTManager) GenerateRefreshToken() (string, time.Time) { + token := uuid.NewString() + expiresAt := time.Now().Add(j.refreshDuration) + return token, expiresAt +} + +func (j *JWTManager) ValidateAccessToken(tokenStr string) (*Claims, error) { + token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (interface{}, error) { + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"]) + } + return j.secret, nil + }) + if err != nil { + return nil, err + } + claims, ok := token.Claims.(*Claims) + if !ok || !token.Valid { + return nil, fmt.Errorf("invalid token") + } + return claims, nil +} + +func (j *JWTManager) AccessDuration() time.Duration { + return j.accessDuration +} diff --git a/backend/internal/auth/jwt_test.go b/backend/internal/auth/jwt_test.go new file mode 100644 index 0000000..d0dd304 --- /dev/null +++ b/backend/internal/auth/jwt_test.go @@ -0,0 +1,86 @@ +package auth + +import ( + "testing" + "time" +) + +func TestGenerateAccessToken(t *testing.T) { + jm := NewJWTManager("test-secret", 15*time.Minute, 720*time.Hour) + + token, err := jm.GenerateAccessToken("user-123", "free") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if token == "" { + t.Fatal("expected non-empty token") + } +} + +func TestValidateAccessToken_Valid(t *testing.T) { + jm := NewJWTManager("test-secret", 15*time.Minute, 720*time.Hour) + + token, _ := jm.GenerateAccessToken("user-123", "free") + claims, err := jm.ValidateAccessToken(token) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if claims.UserID != "user-123" { + t.Errorf("expected user_id 'user-123', got %q", claims.UserID) + } + if claims.Plan != "free" { + t.Errorf("expected plan 'free', got %q", claims.Plan) + } +} + +func TestValidateAccessToken_Expired(t *testing.T) { + jm := NewJWTManager("test-secret", -1*time.Second, 720*time.Hour) + + token, _ := jm.GenerateAccessToken("user-123", "free") + _, err := jm.ValidateAccessToken(token) + if err == nil { + t.Fatal("expected error for expired token") + } +} + +func TestValidateAccessToken_WrongSecret(t *testing.T) { + jm1 := NewJWTManager("secret-1", 15*time.Minute, 720*time.Hour) + jm2 := NewJWTManager("secret-2", 15*time.Minute, 720*time.Hour) + + token, _ := jm1.GenerateAccessToken("user-123", "free") + _, err := jm2.ValidateAccessToken(token) + if err == nil { + t.Fatal("expected error for wrong secret") + } +} + +func TestValidateAccessToken_InvalidToken(t *testing.T) { + jm := NewJWTManager("test-secret", 15*time.Minute, 720*time.Hour) + + _, err := jm.ValidateAccessToken("invalid-token") + if err == nil { + t.Fatal("expected error for invalid token") + } +} + +func TestGenerateRefreshToken(t *testing.T) { + jm := NewJWTManager("test-secret", 15*time.Minute, 720*time.Hour) + + token, expiresAt := jm.GenerateRefreshToken() + if token == "" { + t.Fatal("expected non-empty refresh token") + } + if expiresAt.Before(time.Now()) { + t.Fatal("expected future expiration time") + } +} + +func TestGenerateRefreshToken_Unique(t *testing.T) { + jm := NewJWTManager("test-secret", 15*time.Minute, 720*time.Hour) + + token1, _ := jm.GenerateRefreshToken() + token2, _ := jm.GenerateRefreshToken() + if token1 == token2 { + t.Fatal("expected unique refresh tokens") + } +} diff --git a/backend/internal/auth/mocks/token_verifier.go b/backend/internal/auth/mocks/token_verifier.go new file mode 100644 index 0000000..3a99ec6 --- /dev/null +++ b/backend/internal/auth/mocks/token_verifier.go @@ -0,0 +1,11 @@ +package mocks + +import "context" + +type MockTokenVerifier struct { + VerifyTokenFn func(ctx context.Context, idToken string) (uid, email, name, avatarURL string, err error) +} + +func (m *MockTokenVerifier) VerifyToken(ctx context.Context, idToken string) (uid, email, name, avatarURL string, err error) { + return m.VerifyTokenFn(ctx, idToken) +} diff --git a/backend/internal/auth/service.go b/backend/internal/auth/service.go new file mode 100644 index 0000000..3d2b879 --- /dev/null +++ b/backend/internal/auth/service.go @@ -0,0 +1,91 @@ +package auth + +import ( + "context" + "fmt" + + "github.com/food-ai/backend/internal/user" +) + +type Service struct { + tokenVerifier TokenVerifier + userRepo user.UserRepository + jwtManager *JWTManager +} + +func NewService(tokenVerifier TokenVerifier, userRepo user.UserRepository, jwtManager *JWTManager) *Service { + return &Service{ + tokenVerifier: tokenVerifier, + userRepo: userRepo, + jwtManager: jwtManager, + } +} + +type LoginResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int `json:"expires_in"` + User *user.User `json:"user"` +} + +type RefreshResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int `json:"expires_in"` +} + +func (s *Service) Login(ctx context.Context, firebaseToken string) (*LoginResponse, error) { + uid, email, name, avatarURL, err := s.tokenVerifier.VerifyToken(ctx, firebaseToken) + if err != nil { + return nil, fmt.Errorf("verify firebase token: %w", err) + } + + u, err := s.userRepo.UpsertByFirebaseUID(ctx, uid, email, name, avatarURL) + if err != nil { + return nil, fmt.Errorf("upsert user: %w", err) + } + + accessToken, err := s.jwtManager.GenerateAccessToken(u.ID, u.Plan) + if err != nil { + return nil, fmt.Errorf("generate access token: %w", err) + } + + refreshToken, expiresAt := s.jwtManager.GenerateRefreshToken() + if err := s.userRepo.SetRefreshToken(ctx, u.ID, refreshToken, expiresAt); err != nil { + return nil, fmt.Errorf("set refresh token: %w", err) + } + + return &LoginResponse{ + AccessToken: accessToken, + RefreshToken: refreshToken, + ExpiresIn: int(s.jwtManager.AccessDuration().Seconds()), + User: u, + }, nil +} + +func (s *Service) Refresh(ctx context.Context, refreshToken string) (*RefreshResponse, error) { + u, err := s.userRepo.FindByRefreshToken(ctx, refreshToken) + if err != nil { + return nil, fmt.Errorf("invalid refresh token: %w", err) + } + + accessToken, err := s.jwtManager.GenerateAccessToken(u.ID, u.Plan) + if err != nil { + return nil, fmt.Errorf("generate access token: %w", err) + } + + newRefreshToken, expiresAt := s.jwtManager.GenerateRefreshToken() + if err := s.userRepo.SetRefreshToken(ctx, u.ID, newRefreshToken, expiresAt); err != nil { + return nil, fmt.Errorf("set refresh token: %w", err) + } + + return &RefreshResponse{ + AccessToken: accessToken, + RefreshToken: newRefreshToken, + ExpiresIn: int(s.jwtManager.AccessDuration().Seconds()), + }, nil +} + +func (s *Service) Logout(ctx context.Context, userID string) error { + return s.userRepo.ClearRefreshToken(ctx, userID) +} diff --git a/backend/internal/auth/service_test.go b/backend/internal/auth/service_test.go new file mode 100644 index 0000000..4884968 --- /dev/null +++ b/backend/internal/auth/service_test.go @@ -0,0 +1,213 @@ +package auth + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/food-ai/backend/internal/auth/mocks" + "github.com/food-ai/backend/internal/user" + umocks "github.com/food-ai/backend/internal/user/mocks" +) + +func newTestService(verifier *mocks.MockTokenVerifier, repo *umocks.MockUserRepository) *Service { + jm := NewJWTManager("test-secret", 15*time.Minute, 720*time.Hour) + return NewService(verifier, repo, jm) +} + +func TestLogin_Success(t *testing.T) { + verifier := &mocks.MockTokenVerifier{ + VerifyTokenFn: func(ctx context.Context, idToken string) (string, string, string, string, error) { + return "fb-uid", "test@example.com", "Test User", "https://avatar.url", nil + }, + } + repo := &umocks.MockUserRepository{ + UpsertByFirebaseUIDFn: func(ctx context.Context, uid, email, name, avatarURL string) (*user.User, error) { + return &user.User{ID: "user-1", Email: email, Name: name, Plan: "free"}, nil + }, + SetRefreshTokenFn: func(ctx context.Context, id, token string, expiresAt time.Time) error { + return nil + }, + } + + svc := newTestService(verifier, repo) + resp, err := svc.Login(context.Background(), "firebase-token") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.AccessToken == "" { + t.Error("expected non-empty access token") + } + if resp.RefreshToken == "" { + t.Error("expected non-empty refresh token") + } + if resp.User.ID != "user-1" { + t.Errorf("expected user ID 'user-1', got %q", resp.User.ID) + } +} + +func TestLogin_InvalidFirebaseToken(t *testing.T) { + verifier := &mocks.MockTokenVerifier{ + VerifyTokenFn: func(ctx context.Context, idToken string) (string, string, string, string, error) { + return "", "", "", "", fmt.Errorf("invalid token") + }, + } + repo := &umocks.MockUserRepository{} + + svc := newTestService(verifier, repo) + _, err := svc.Login(context.Background(), "bad-token") + if err == nil { + t.Fatal("expected error for invalid firebase token") + } +} + +func TestLogin_UpsertError(t *testing.T) { + verifier := &mocks.MockTokenVerifier{ + VerifyTokenFn: func(ctx context.Context, idToken string) (string, string, string, string, error) { + return "fb-uid", "test@example.com", "Test", "", nil + }, + } + repo := &umocks.MockUserRepository{ + UpsertByFirebaseUIDFn: func(ctx context.Context, uid, email, name, avatarURL string) (*user.User, error) { + return nil, fmt.Errorf("db error") + }, + } + + svc := newTestService(verifier, repo) + _, err := svc.Login(context.Background(), "token") + if err == nil { + t.Fatal("expected error for upsert failure") + } +} + +func TestLogin_SetRefreshTokenError(t *testing.T) { + verifier := &mocks.MockTokenVerifier{ + VerifyTokenFn: func(ctx context.Context, idToken string) (string, string, string, string, error) { + return "fb-uid", "test@example.com", "Test", "", nil + }, + } + repo := &umocks.MockUserRepository{ + UpsertByFirebaseUIDFn: func(ctx context.Context, uid, email, name, avatarURL string) (*user.User, error) { + return &user.User{ID: "user-1", Plan: "free"}, nil + }, + SetRefreshTokenFn: func(ctx context.Context, id, token string, expiresAt time.Time) error { + return fmt.Errorf("db error") + }, + } + + svc := newTestService(verifier, repo) + _, err := svc.Login(context.Background(), "token") + if err == nil { + t.Fatal("expected error for set refresh token failure") + } +} + +func TestRefresh_Success(t *testing.T) { + repo := &umocks.MockUserRepository{ + FindByRefreshTokenFn: func(ctx context.Context, token string) (*user.User, error) { + return &user.User{ID: "user-1", Plan: "free"}, nil + }, + SetRefreshTokenFn: func(ctx context.Context, id, token string, expiresAt time.Time) error { + return nil + }, + } + verifier := &mocks.MockTokenVerifier{} + + svc := newTestService(verifier, repo) + resp, err := svc.Refresh(context.Background(), "valid-refresh-token") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.AccessToken == "" { + t.Error("expected non-empty access token") + } + if resp.RefreshToken == "" { + t.Error("expected non-empty refresh token") + } +} + +func TestRefresh_InvalidToken(t *testing.T) { + repo := &umocks.MockUserRepository{ + FindByRefreshTokenFn: func(ctx context.Context, token string) (*user.User, error) { + return nil, fmt.Errorf("not found") + }, + } + verifier := &mocks.MockTokenVerifier{} + + svc := newTestService(verifier, repo) + _, err := svc.Refresh(context.Background(), "bad-token") + if err == nil { + t.Fatal("expected error for invalid refresh token") + } +} + +func TestRefresh_SetRefreshTokenError(t *testing.T) { + repo := &umocks.MockUserRepository{ + FindByRefreshTokenFn: func(ctx context.Context, token string) (*user.User, error) { + return &user.User{ID: "user-1", Plan: "free"}, nil + }, + SetRefreshTokenFn: func(ctx context.Context, id, token string, expiresAt time.Time) error { + return fmt.Errorf("db error") + }, + } + verifier := &mocks.MockTokenVerifier{} + + svc := newTestService(verifier, repo) + _, err := svc.Refresh(context.Background(), "valid-token") + if err == nil { + t.Fatal("expected error") + } +} + +func TestLogout_Success(t *testing.T) { + repo := &umocks.MockUserRepository{ + ClearRefreshTokenFn: func(ctx context.Context, id string) error { + return nil + }, + } + verifier := &mocks.MockTokenVerifier{} + + svc := newTestService(verifier, repo) + err := svc.Logout(context.Background(), "user-1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestLogout_Error(t *testing.T) { + repo := &umocks.MockUserRepository{ + ClearRefreshTokenFn: func(ctx context.Context, id string) error { + return fmt.Errorf("db error") + }, + } + verifier := &mocks.MockTokenVerifier{} + + svc := newTestService(verifier, repo) + err := svc.Logout(context.Background(), "user-1") + if err == nil { + t.Fatal("expected error") + } +} + +func TestLogin_ExpiresIn(t *testing.T) { + verifier := &mocks.MockTokenVerifier{ + VerifyTokenFn: func(ctx context.Context, idToken string) (string, string, string, string, error) { + return "fb-uid", "test@example.com", "Test", "", nil + }, + } + repo := &umocks.MockUserRepository{ + UpsertByFirebaseUIDFn: func(ctx context.Context, uid, email, name, avatarURL string) (*user.User, error) { + return &user.User{ID: "user-1", Plan: "free"}, nil + }, + SetRefreshTokenFn: func(ctx context.Context, id, token string, expiresAt time.Time) error { + return nil + }, + } + + svc := newTestService(verifier, repo) + resp, _ := svc.Login(context.Background(), "token") + if resp.ExpiresIn != 900 { + t.Errorf("expected expires_in 900, got %d", resp.ExpiresIn) + } +} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go new file mode 100644 index 0000000..0197c97 --- /dev/null +++ b/backend/internal/config/config.go @@ -0,0 +1,31 @@ +package config + +import ( + "time" + + "github.com/kelseyhightower/envconfig" +) + +type Config struct { + Port int `envconfig:"PORT" default:"8080"` + DatabaseURL string `envconfig:"DATABASE_URL" required:"true"` + + // Firebase + FirebaseCredentialsFile string `envconfig:"FIREBASE_CREDENTIALS_FILE" required:"true"` + + // JWT + JWTSecret string `envconfig:"JWT_SECRET" required:"true"` + JWTAccessDuration time.Duration `envconfig:"JWT_ACCESS_DURATION" default:"15m"` + JWTRefreshDuration time.Duration `envconfig:"JWT_REFRESH_DURATION" default:"720h"` + + // CORS + AllowedOrigins []string `envconfig:"ALLOWED_ORIGINS" default:"http://localhost:3000"` +} + +func Load() (*Config, error) { + var cfg Config + if err := envconfig.Process("", &cfg); err != nil { + return nil, err + } + return &cfg, nil +} diff --git a/backend/internal/database/postgres.go b/backend/internal/database/postgres.go new file mode 100644 index 0000000..b862653 --- /dev/null +++ b/backend/internal/database/postgres.go @@ -0,0 +1,32 @@ +package database + +import ( + "context" + "fmt" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +func NewPool(ctx context.Context, databaseURL string) (*pgxpool.Pool, error) { + config, err := pgxpool.ParseConfig(databaseURL) + if err != nil { + return nil, fmt.Errorf("parse config: %w", err) + } + + config.MaxConns = 20 + config.MinConns = 5 + config.MaxConnLifetime = 30 * time.Minute + config.MaxConnIdleTime = 5 * time.Minute + + pool, err := pgxpool.NewWithConfig(ctx, config) + if err != nil { + return nil, fmt.Errorf("create pool: %w", err) + } + + if err := pool.Ping(ctx); err != nil { + return nil, fmt.Errorf("ping: %w", err) + } + + return pool, nil +} diff --git a/backend/internal/middleware/auth.go b/backend/internal/middleware/auth.go new file mode 100644 index 0000000..0299f86 --- /dev/null +++ b/backend/internal/middleware/auth.go @@ -0,0 +1,56 @@ +package middleware + +import ( + "context" + "net/http" + "strings" +) + +const ( + userIDKey contextKey = "user_id" + userPlanKey contextKey = "user_plan" +) + +// TokenClaims represents the result of validating an access token. +type TokenClaims struct { + UserID string + Plan string +} + +// AccessTokenValidator validates JWT access tokens. +type AccessTokenValidator interface { + ValidateAccessToken(tokenStr string) (*TokenClaims, error) +} + +func Auth(validator AccessTokenValidator) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + header := r.Header.Get("Authorization") + if !strings.HasPrefix(header, "Bearer ") { + http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) + return + } + + tokenStr := strings.TrimPrefix(header, "Bearer ") + claims, err := validator.ValidateAccessToken(tokenStr) + if err != nil { + http.Error(w, `{"error":"invalid token"}`, http.StatusUnauthorized) + return + } + + ctx := context.WithValue(r.Context(), userIDKey, claims.UserID) + ctx = context.WithValue(ctx, userPlanKey, claims.Plan) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +func UserIDFromCtx(ctx context.Context) string { + id, _ := ctx.Value(userIDKey).(string) + return id +} + +func UserPlanFromCtx(ctx context.Context) string { + plan, _ := ctx.Value(userPlanKey).(string) + return plan +} diff --git a/backend/internal/middleware/auth_test.go b/backend/internal/middleware/auth_test.go new file mode 100644 index 0000000..436f746 --- /dev/null +++ b/backend/internal/middleware/auth_test.go @@ -0,0 +1,192 @@ +package middleware + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +// testJWTClaims mirrors auth.Claims for test token generation without importing auth. +type testJWTClaims struct { + UserID string `json:"user_id"` + Plan string `json:"plan"` + jwt.RegisteredClaims +} + +func generateTestToken(secret string, userID, plan string, duration time.Duration) string { + claims := testJWTClaims{ + UserID: userID, + Plan: plan, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(duration)), + IssuedAt: jwt.NewNumericDate(time.Now()), + }, + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + s, _ := token.SignedString([]byte(secret)) + return s +} + +// testValidator implements AccessTokenValidator for tests. +type testAccessValidator struct { + secret string +} + +func (v *testAccessValidator) ValidateAccessToken(tokenStr string) (*TokenClaims, error) { + token, err := jwt.ParseWithClaims(tokenStr, &testJWTClaims{}, func(t *jwt.Token) (interface{}, error) { + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method") + } + return []byte(v.secret), nil + }) + if err != nil { + return nil, err + } + claims, ok := token.Claims.(*testJWTClaims) + if !ok || !token.Valid { + return nil, fmt.Errorf("invalid token") + } + return &TokenClaims{UserID: claims.UserID, Plan: claims.Plan}, nil +} + +// failingValidator always returns an error. +type failingValidator struct{} + +func (v *failingValidator) ValidateAccessToken(tokenStr string) (*TokenClaims, error) { + return nil, fmt.Errorf("invalid token") +} + +func TestAuth_ValidToken(t *testing.T) { + validator := &testAccessValidator{secret: "test-secret"} + token := generateTestToken("test-secret", "user-1", "free", 15*time.Minute) + + handler := Auth(validator)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + userID := UserIDFromCtx(r.Context()) + if userID != "user-1" { + t.Errorf("expected user-1, got %s", userID) + } + plan := UserPlanFromCtx(r.Context()) + if plan != "free" { + t.Errorf("expected free, got %s", plan) + } + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest("GET", "/", nil) + req.Header.Set("Authorization", "Bearer "+token) + rr := httptest.NewRecorder() + + handler.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Errorf("expected 200, got %d", rr.Code) + } +} + +func TestAuth_MissingHeader(t *testing.T) { + validator := &testAccessValidator{secret: "test-secret"} + + handler := Auth(validator)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Error("handler should not be called") + })) + + req := httptest.NewRequest("GET", "/", nil) + rr := httptest.NewRecorder() + + handler.ServeHTTP(rr, req) + if rr.Code != http.StatusUnauthorized { + t.Errorf("expected 401, got %d", rr.Code) + } +} + +func TestAuth_InvalidBearerFormat(t *testing.T) { + validator := &testAccessValidator{secret: "test-secret"} + + handler := Auth(validator)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Error("handler should not be called") + })) + + req := httptest.NewRequest("GET", "/", nil) + req.Header.Set("Authorization", "Basic abc123") + rr := httptest.NewRecorder() + + handler.ServeHTTP(rr, req) + if rr.Code != http.StatusUnauthorized { + t.Errorf("expected 401, got %d", rr.Code) + } +} + +func TestAuth_ExpiredToken(t *testing.T) { + validator := &testAccessValidator{secret: "test-secret"} + token := generateTestToken("test-secret", "user-1", "free", -1*time.Second) + + handler := Auth(validator)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Error("handler should not be called") + })) + + req := httptest.NewRequest("GET", "/", nil) + req.Header.Set("Authorization", "Bearer "+token) + rr := httptest.NewRecorder() + + handler.ServeHTTP(rr, req) + if rr.Code != http.StatusUnauthorized { + t.Errorf("expected 401, got %d", rr.Code) + } +} + +func TestAuth_InvalidToken(t *testing.T) { + validator := &testAccessValidator{secret: "test-secret"} + + handler := Auth(validator)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Error("handler should not be called") + })) + + req := httptest.NewRequest("GET", "/", nil) + req.Header.Set("Authorization", "Bearer invalid-token") + rr := httptest.NewRecorder() + + handler.ServeHTTP(rr, req) + if rr.Code != http.StatusUnauthorized { + t.Errorf("expected 401, got %d", rr.Code) + } +} + +func TestAuth_PaidPlan(t *testing.T) { + validator := &testAccessValidator{secret: "test-secret"} + token := generateTestToken("test-secret", "user-1", "paid", 15*time.Minute) + + handler := Auth(validator)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + plan := UserPlanFromCtx(r.Context()) + if plan != "paid" { + t.Errorf("expected paid, got %s", plan) + } + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest("GET", "/", nil) + req.Header.Set("Authorization", "Bearer "+token) + rr := httptest.NewRecorder() + + handler.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Errorf("expected 200, got %d", rr.Code) + } +} + +func TestAuth_EmptyBearer(t *testing.T) { + handler := Auth(&failingValidator{})(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Error("handler should not be called") + })) + + req := httptest.NewRequest("GET", "/", nil) + req.Header.Set("Authorization", "Bearer ") + rr := httptest.NewRecorder() + + handler.ServeHTTP(rr, req) + if rr.Code != http.StatusUnauthorized { + t.Errorf("expected 401, got %d", rr.Code) + } +} diff --git a/backend/internal/middleware/cors.go b/backend/internal/middleware/cors.go new file mode 100644 index 0000000..bc96af2 --- /dev/null +++ b/backend/internal/middleware/cors.go @@ -0,0 +1,18 @@ +package middleware + +import ( + "net/http" + + "github.com/go-chi/cors" +) + +func CORS(allowedOrigins []string) func(http.Handler) http.Handler { + return cors.Handler(cors.Options{ + AllowedOrigins: allowedOrigins, + AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}, + AllowedHeaders: []string{"Authorization", "Content-Type", "X-Request-ID"}, + ExposedHeaders: []string{"X-Request-ID"}, + AllowCredentials: true, + MaxAge: 300, + }) +} diff --git a/backend/internal/middleware/logging.go b/backend/internal/middleware/logging.go new file mode 100644 index 0000000..fb9bafe --- /dev/null +++ b/backend/internal/middleware/logging.go @@ -0,0 +1,34 @@ +package middleware + +import ( + "log/slog" + "net/http" + "time" +) + +type responseWriter struct { + http.ResponseWriter + statusCode int +} + +func (rw *responseWriter) WriteHeader(code int) { + rw.statusCode = code + rw.ResponseWriter.WriteHeader(code) +} + +func Logging(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + ww := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK} + + next.ServeHTTP(ww, r) + + slog.Info("request", + "method", r.Method, + "path", r.URL.Path, + "status", ww.statusCode, + "duration_ms", time.Since(start).Milliseconds(), + "request_id", RequestIDFromCtx(r.Context()), + ) + }) +} diff --git a/backend/internal/middleware/recovery.go b/backend/internal/middleware/recovery.go new file mode 100644 index 0000000..b61fcd5 --- /dev/null +++ b/backend/internal/middleware/recovery.go @@ -0,0 +1,23 @@ +package middleware + +import ( + "log/slog" + "net/http" + "runtime/debug" +) + +func Recovery(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if err := recover(); err != nil { + slog.Error("panic recovered", + "error", err, + "stack", string(debug.Stack()), + "request_id", RequestIDFromCtx(r.Context()), + ) + http.Error(w, `{"error":"internal server error"}`, http.StatusInternalServerError) + } + }() + next.ServeHTTP(w, r) + }) +} diff --git a/backend/internal/middleware/recovery_test.go b/backend/internal/middleware/recovery_test.go new file mode 100644 index 0000000..b401987 --- /dev/null +++ b/backend/internal/middleware/recovery_test.go @@ -0,0 +1,49 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestRecovery_NoPanic(t *testing.T) { + handler := Recovery(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + req := httptest.NewRequest("GET", "/", nil) + rr := httptest.NewRecorder() + + handler.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Errorf("expected 200, got %d", rr.Code) + } +} + +func TestRecovery_CatchesPanic(t *testing.T) { + handler := Recovery(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + panic("test panic") + })) + + req := httptest.NewRequest("GET", "/", nil) + rr := httptest.NewRecorder() + + handler.ServeHTTP(rr, req) + if rr.Code != http.StatusInternalServerError { + t.Errorf("expected 500, got %d", rr.Code) + } +} + +func TestRecovery_CatchesPanicWithError(t *testing.T) { + handler := Recovery(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + panic(42) + })) + + req := httptest.NewRequest("GET", "/", nil) + rr := httptest.NewRecorder() + + handler.ServeHTTP(rr, req) + if rr.Code != http.StatusInternalServerError { + t.Errorf("expected 500, got %d", rr.Code) + } +} diff --git a/backend/internal/middleware/request_id.go b/backend/internal/middleware/request_id.go new file mode 100644 index 0000000..82ca259 --- /dev/null +++ b/backend/internal/middleware/request_id.go @@ -0,0 +1,29 @@ +package middleware + +import ( + "context" + "net/http" + + "github.com/google/uuid" +) + +type contextKey string + +const requestIDKey contextKey = "request_id" + +func RequestID(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + id := r.Header.Get("X-Request-ID") + if id == "" { + id = uuid.NewString() + } + ctx := context.WithValue(r.Context(), requestIDKey, id) + w.Header().Set("X-Request-ID", id) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +func RequestIDFromCtx(ctx context.Context) string { + id, _ := ctx.Value(requestIDKey).(string) + return id +} diff --git a/backend/internal/middleware/request_id_test.go b/backend/internal/middleware/request_id_test.go new file mode 100644 index 0000000..effc5a4 --- /dev/null +++ b/backend/internal/middleware/request_id_test.go @@ -0,0 +1,59 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestRequestID_GeneratesNew(t *testing.T) { + handler := RequestID(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + id := RequestIDFromCtx(r.Context()) + if id == "" { + t.Error("expected non-empty request ID in context") + } + })) + + req := httptest.NewRequest("GET", "/", nil) + rr := httptest.NewRecorder() + + handler.ServeHTTP(rr, req) + if rr.Header().Get("X-Request-ID") == "" { + t.Error("expected X-Request-ID in response header") + } +} + +func TestRequestID_PreservesExisting(t *testing.T) { + handler := RequestID(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + id := RequestIDFromCtx(r.Context()) + if id != "existing-id" { + t.Errorf("expected 'existing-id', got %q", id) + } + })) + + req := httptest.NewRequest("GET", "/", nil) + req.Header.Set("X-Request-ID", "existing-id") + rr := httptest.NewRecorder() + + handler.ServeHTTP(rr, req) + if rr.Header().Get("X-Request-ID") != "existing-id" { + t.Error("expected preserved X-Request-ID") + } +} + +func TestRequestID_UniquePerRequest(t *testing.T) { + var ids []string + handler := RequestID(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ids = append(ids, RequestIDFromCtx(r.Context())) + })) + + for i := 0; i < 3; i++ { + req := httptest.NewRequest("GET", "/", nil) + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + } + + if ids[0] == ids[1] || ids[1] == ids[2] { + t.Error("expected unique IDs for each request") + } +} diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go new file mode 100644 index 0000000..85c1502 --- /dev/null +++ b/backend/internal/server/server.go @@ -0,0 +1,61 @@ +package server + +import ( + "encoding/json" + "net/http" + + "github.com/food-ai/backend/internal/auth" + "github.com/food-ai/backend/internal/middleware" + "github.com/food-ai/backend/internal/user" + "github.com/go-chi/chi/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +func NewRouter( + pool *pgxpool.Pool, + authHandler *auth.Handler, + userHandler *user.Handler, + authMiddleware func(http.Handler) http.Handler, + allowedOrigins []string, +) *chi.Mux { + r := chi.NewRouter() + + // Global middleware + r.Use(middleware.RequestID) + r.Use(middleware.Logging) + r.Use(middleware.Recovery) + r.Use(middleware.CORS(allowedOrigins)) + + // Public + r.Get("/health", healthCheck(pool)) + r.Route("/auth", func(r chi.Router) { + r.Post("/login", authHandler.Login) + r.Post("/refresh", authHandler.Refresh) + r.Post("/logout", authHandler.Logout) + }) + + // Protected + r.Group(func(r chi.Router) { + r.Use(authMiddleware) + r.Get("/profile", userHandler.Get) + r.Put("/profile", userHandler.Update) + }) + + return r +} + +func healthCheck(pool *pgxpool.Pool) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + dbStatus := "connected" + if err := pool.Ping(r.Context()); err != nil { + dbStatus = "disconnected" + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{ + "status": "ok", + "version": "0.1.0", + "db": dbStatus, + }) + } +} diff --git a/backend/internal/testutil/postgres.go b/backend/internal/testutil/postgres.go new file mode 100644 index 0000000..63e3474 --- /dev/null +++ b/backend/internal/testutil/postgres.go @@ -0,0 +1,77 @@ +package testutil + +import ( + "context" + "database/sql" + "fmt" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" + _ "github.com/jackc/pgx/v5/stdlib" + "github.com/pressly/goose/v3" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" +) + +func SetupTestDB(t *testing.T) *pgxpool.Pool { + t.Helper() + ctx := context.Background() + + container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: testcontainers.ContainerRequest{ + Image: "postgres:16-alpine", + ExposedPorts: []string{"5432/tcp"}, + Env: map[string]string{ + "POSTGRES_DB": "test_db", + "POSTGRES_USER": "test_user", + "POSTGRES_PASSWORD": "test_pass", + }, + WaitingFor: wait.ForListeningPort("5432/tcp"), + }, + Started: true, + }) + if err != nil { + t.Fatalf("failed to start container: %v", err) + } + + t.Cleanup(func() { + container.Terminate(ctx) + }) + + host, err := container.Host(ctx) + if err != nil { + t.Fatalf("failed to get host: %v", err) + } + + port, err := container.MappedPort(ctx, "5432") + if err != nil { + t.Fatalf("failed to get port: %v", err) + } + + dsn := fmt.Sprintf("postgres://test_user:test_pass@%s:%s/test_db?sslmode=disable", host, port.Port()) + + // Run migrations with goose + db, err := sql.Open("pgx", dsn) + if err != nil { + t.Fatalf("failed to open db for migrations: %v", err) + } + defer db.Close() + + if err := goose.Up(db, "../../../migrations"); err != nil { + // Try relative path from different test locations + if err2 := goose.Up(db, "../../migrations"); err2 != nil { + t.Fatalf("failed to run migrations: %v (also tried: %v)", err, err2) + } + } + + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Fatalf("failed to create pool: %v", err) + } + + t.Cleanup(func() { + pool.Close() + }) + + return pool +} diff --git a/backend/internal/user/calories.go b/backend/internal/user/calories.go new file mode 100644 index 0000000..5ba6d6a --- /dev/null +++ b/backend/internal/user/calories.go @@ -0,0 +1,51 @@ +package user + +import "math" + +// Activity level multipliers (Mifflin-St Jeor). +var activityMultiplier = map[string]float64{ + "low": 1.375, + "moderate": 1.55, + "high": 1.725, +} + +// Goal adjustments in kcal. +var goalAdjustment = map[string]float64{ + "lose": -500, + "maintain": 0, + "gain": 300, +} + +// CalculateDailyCalories computes the daily calorie target using the +// Mifflin-St Jeor equation. Returns nil if any required parameter is missing. +func CalculateDailyCalories(heightCM *int, weightKG *float64, age *int, gender, activity, goal *string) *int { + if heightCM == nil || weightKG == nil || age == nil || gender == nil || activity == nil || goal == nil { + return nil + } + + // BMR = 10 * weight(kg) + 6.25 * height(cm) - 5 * age + bmr := 10**weightKG + 6.25*float64(*heightCM) - 5*float64(*age) + + switch *gender { + case "male": + bmr += 5 + case "female": + bmr -= 161 + default: + return nil + } + + mult, ok := activityMultiplier[*activity] + if !ok { + return nil + } + + adj, ok := goalAdjustment[*goal] + if !ok { + return nil + } + + tdee := bmr * mult + result := int(math.Round(tdee + adj)) + return &result +} diff --git a/backend/internal/user/calories_test.go b/backend/internal/user/calories_test.go new file mode 100644 index 0000000..952b5ff --- /dev/null +++ b/backend/internal/user/calories_test.go @@ -0,0 +1,73 @@ +package user + +import ( + "testing" +) + +func ptr[T any](v T) *T { return &v } + +func TestCalculateDailyCalories_MaleMaintain(t *testing.T) { + cal := CalculateDailyCalories(ptr(180), ptr(80.0), ptr(30), ptr("male"), ptr("moderate"), ptr("maintain")) + if cal == nil { + t.Fatal("expected non-nil result") + } + // BMR = 10*80 + 6.25*180 - 5*30 + 5 = 800 + 1125 - 150 + 5 = 1780 + // TDEE = 1780 * 1.55 = 2759 + if *cal != 2759 { + t.Errorf("expected 2759, got %d", *cal) + } +} + +func TestCalculateDailyCalories_FemaleLose(t *testing.T) { + cal := CalculateDailyCalories(ptr(165), ptr(60.0), ptr(25), ptr("female"), ptr("low"), ptr("lose")) + if cal == nil { + t.Fatal("expected non-nil result") + } + // BMR = 10*60 + 6.25*165 - 5*25 - 161 = 600 + 1031.25 - 125 - 161 = 1345.25 + // TDEE = 1345.25 * 1.375 = 1849.72 + // Goal: -500 = 1349.72 → 1350 + if *cal != 1350 { + t.Errorf("expected 1350, got %d", *cal) + } +} + +func TestCalculateDailyCalories_MaleGain(t *testing.T) { + cal := CalculateDailyCalories(ptr(175), ptr(70.0), ptr(28), ptr("male"), ptr("high"), ptr("gain")) + if cal == nil { + t.Fatal("expected non-nil result") + } + // BMR = 10*70 + 6.25*175 - 5*28 + 5 = 700 + 1093.75 - 140 + 5 = 1658.75 + // TDEE = 1658.75 * 1.725 = 2861.34 + // Goal: +300 = 3161.34 → 3161 + if *cal != 3161 { + t.Errorf("expected 3161, got %d", *cal) + } +} + +func TestCalculateDailyCalories_NilHeight(t *testing.T) { + cal := CalculateDailyCalories(nil, ptr(80.0), ptr(30), ptr("male"), ptr("moderate"), ptr("maintain")) + if cal != nil { + t.Fatal("expected nil when height is nil") + } +} + +func TestCalculateDailyCalories_NilWeight(t *testing.T) { + cal := CalculateDailyCalories(ptr(180), nil, ptr(30), ptr("male"), ptr("moderate"), ptr("maintain")) + if cal != nil { + t.Fatal("expected nil when weight is nil") + } +} + +func TestCalculateDailyCalories_InvalidGender(t *testing.T) { + cal := CalculateDailyCalories(ptr(180), ptr(80.0), ptr(30), ptr("other"), ptr("moderate"), ptr("maintain")) + if cal != nil { + t.Fatal("expected nil for invalid gender") + } +} + +func TestCalculateDailyCalories_InvalidActivity(t *testing.T) { + cal := CalculateDailyCalories(ptr(180), ptr(80.0), ptr(30), ptr("male"), ptr("extreme"), ptr("maintain")) + if cal != nil { + t.Fatal("expected nil for invalid activity") + } +} diff --git a/backend/internal/user/handler.go b/backend/internal/user/handler.go new file mode 100644 index 0000000..4a3d12a --- /dev/null +++ b/backend/internal/user/handler.go @@ -0,0 +1,78 @@ +package user + +import ( + "encoding/json" + "log/slog" + "net/http" + + "github.com/food-ai/backend/internal/middleware" +) + +const maxRequestBodySize = 1 << 20 // 1 MB + +type Handler struct { + service *Service +} + +func NewHandler(service *Service) *Handler { + return &Handler{service: service} +} + +func (h *Handler) Get(w http.ResponseWriter, r *http.Request) { + userID := middleware.UserIDFromCtx(r.Context()) + if userID == "" { + writeErrorJSON(w, http.StatusUnauthorized, "unauthorized") + return + } + + u, err := h.service.GetProfile(r.Context(), userID) + if err != nil { + writeErrorJSON(w, http.StatusNotFound, "user not found") + return + } + + writeJSON(w, http.StatusOK, u) +} + +func (h *Handler) Update(w http.ResponseWriter, r *http.Request) { + userID := middleware.UserIDFromCtx(r.Context()) + if userID == "" { + writeErrorJSON(w, http.StatusUnauthorized, "unauthorized") + return + } + + r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize) + var req UpdateProfileRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErrorJSON(w, http.StatusBadRequest, "invalid request body") + return + } + + u, err := h.service.UpdateProfile(r.Context(), userID, req) + if err != nil { + writeErrorJSON(w, http.StatusBadRequest, err.Error()) + return + } + + writeJSON(w, http.StatusOK, u) +} + +type errorResponse struct { + Error string `json:"error"` +} + +func writeErrorJSON(w http.ResponseWriter, status int, msg string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if err := json.NewEncoder(w).Encode(errorResponse{Error: msg}); err != nil { + slog.Error("failed to write error response", "err", err) + } +} + +func writeJSON(w http.ResponseWriter, status int, v interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if err := json.NewEncoder(w).Encode(v); err != nil { + slog.Error("failed to write JSON response", "err", err) + } +} diff --git a/backend/internal/user/mocks/repository.go b/backend/internal/user/mocks/repository.go new file mode 100644 index 0000000..df80ee5 --- /dev/null +++ b/backend/internal/user/mocks/repository.go @@ -0,0 +1,46 @@ +package mocks + +import ( + "context" + "time" + + "github.com/food-ai/backend/internal/user" +) + +type MockUserRepository struct { + UpsertByFirebaseUIDFn func(ctx context.Context, uid, email, name, avatarURL string) (*user.User, error) + GetByIDFn func(ctx context.Context, id string) (*user.User, error) + UpdateFn func(ctx context.Context, id string, req user.UpdateProfileRequest) (*user.User, error) + UpdateInTxFn func(ctx context.Context, id string, profileReq user.UpdateProfileRequest, caloriesReq *user.UpdateProfileRequest) (*user.User, error) + SetRefreshTokenFn func(ctx context.Context, id, token string, expiresAt time.Time) error + FindByRefreshTokenFn func(ctx context.Context, token string) (*user.User, error) + ClearRefreshTokenFn func(ctx context.Context, id string) error +} + +func (m *MockUserRepository) UpsertByFirebaseUID(ctx context.Context, uid, email, name, avatarURL string) (*user.User, error) { + return m.UpsertByFirebaseUIDFn(ctx, uid, email, name, avatarURL) +} + +func (m *MockUserRepository) GetByID(ctx context.Context, id string) (*user.User, error) { + return m.GetByIDFn(ctx, id) +} + +func (m *MockUserRepository) Update(ctx context.Context, id string, req user.UpdateProfileRequest) (*user.User, error) { + return m.UpdateFn(ctx, id, req) +} + +func (m *MockUserRepository) UpdateInTx(ctx context.Context, id string, profileReq user.UpdateProfileRequest, caloriesReq *user.UpdateProfileRequest) (*user.User, error) { + return m.UpdateInTxFn(ctx, id, profileReq, caloriesReq) +} + +func (m *MockUserRepository) SetRefreshToken(ctx context.Context, id, token string, expiresAt time.Time) error { + return m.SetRefreshTokenFn(ctx, id, token, expiresAt) +} + +func (m *MockUserRepository) FindByRefreshToken(ctx context.Context, token string) (*user.User, error) { + return m.FindByRefreshTokenFn(ctx, token) +} + +func (m *MockUserRepository) ClearRefreshToken(ctx context.Context, id string) error { + return m.ClearRefreshTokenFn(ctx, id) +} diff --git a/backend/internal/user/model.go b/backend/internal/user/model.go new file mode 100644 index 0000000..cf23c4f --- /dev/null +++ b/backend/internal/user/model.go @@ -0,0 +1,44 @@ +package user + +import ( + "encoding/json" + "time" +) + +type User struct { + ID string `json:"id"` + FirebaseUID string `json:"-"` + Email string `json:"email"` + Name string `json:"name"` + AvatarURL *string `json:"avatar_url"` + HeightCM *int `json:"height_cm"` + WeightKG *float64 `json:"weight_kg"` + Age *int `json:"age"` + Gender *string `json:"gender"` + Activity *string `json:"activity"` + Goal *string `json:"goal"` + DailyCalories *int `json:"daily_calories"` + Plan string `json:"plan"` + Preferences json.RawMessage `json:"preferences"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type UpdateProfileRequest struct { + Name *string `json:"name"` + HeightCM *int `json:"height_cm"` + WeightKG *float64 `json:"weight_kg"` + Age *int `json:"age"` + Gender *string `json:"gender"` + Activity *string `json:"activity"` + Goal *string `json:"goal"` + Preferences *json.RawMessage `json:"preferences"` + DailyCalories *int `json:"-"` // internal, set by service +} + +// HasBodyParams returns true if any body parameter is being updated +// that would require recalculation of daily calories. +func (r *UpdateProfileRequest) HasBodyParams() bool { + return r.HeightCM != nil || r.WeightKG != nil || r.Age != nil || + r.Gender != nil || r.Activity != nil || r.Goal != nil +} diff --git a/backend/internal/user/repository.go b/backend/internal/user/repository.go new file mode 100644 index 0000000..3689817 --- /dev/null +++ b/backend/internal/user/repository.go @@ -0,0 +1,218 @@ +package user + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// UserRepository defines the persistence interface for user data. +type UserRepository interface { + UpsertByFirebaseUID(ctx context.Context, uid, email, name, avatarURL string) (*User, error) + GetByID(ctx context.Context, id string) (*User, error) + Update(ctx context.Context, id string, req UpdateProfileRequest) (*User, error) + UpdateInTx(ctx context.Context, id string, profileReq UpdateProfileRequest, caloriesReq *UpdateProfileRequest) (*User, error) + SetRefreshToken(ctx context.Context, id, token string, expiresAt time.Time) error + FindByRefreshToken(ctx context.Context, token string) (*User, error) + ClearRefreshToken(ctx context.Context, id string) error +} + +type Repository struct { + pool *pgxpool.Pool +} + +func NewRepository(pool *pgxpool.Pool) *Repository { + return &Repository{pool: pool} +} + +func (r *Repository) UpsertByFirebaseUID(ctx context.Context, uid, email, name, avatarURL string) (*User, error) { + var avatarPtr *string + if avatarURL != "" { + avatarPtr = &avatarURL + } + + query := ` + INSERT INTO users (firebase_uid, email, name, avatar_url) + VALUES ($1, $2, $3, $4) + ON CONFLICT (firebase_uid) DO UPDATE SET + email = EXCLUDED.email, + name = CASE WHEN users.name = '' THEN EXCLUDED.name ELSE users.name END, + avatar_url = COALESCE(EXCLUDED.avatar_url, users.avatar_url), + updated_at = now() + RETURNING id, firebase_uid, email, name, avatar_url, + height_cm, weight_kg, age, gender, activity, goal, daily_calories, + plan, preferences, created_at, updated_at` + + return r.scanUser(r.pool.QueryRow(ctx, query, uid, email, name, avatarPtr)) +} + +func (r *Repository) GetByID(ctx context.Context, id string) (*User, error) { + query := ` + SELECT id, firebase_uid, email, name, avatar_url, + height_cm, weight_kg, age, gender, activity, goal, daily_calories, + plan, preferences, created_at, updated_at + FROM users WHERE id = $1` + + return r.scanUser(r.pool.QueryRow(ctx, query, id)) +} + +func (r *Repository) Update(ctx context.Context, id string, req UpdateProfileRequest) (*User, error) { + query, args := buildUpdateQuery(id, req) + if query == "" { + return r.GetByID(ctx, id) + } + return r.scanUser(r.pool.QueryRow(ctx, query, args...)) +} + +// buildUpdateQuery constructs a dynamic UPDATE query from the request fields. +// Returns empty string if there are no fields to update. +func buildUpdateQuery(id string, req UpdateProfileRequest) (string, []interface{}) { + setClauses := []string{} + args := []interface{}{} + argIdx := 1 + + if req.Name != nil { + setClauses = append(setClauses, fmt.Sprintf("name = $%d", argIdx)) + args = append(args, *req.Name) + argIdx++ + } + if req.HeightCM != nil { + setClauses = append(setClauses, fmt.Sprintf("height_cm = $%d", argIdx)) + args = append(args, *req.HeightCM) + argIdx++ + } + if req.WeightKG != nil { + setClauses = append(setClauses, fmt.Sprintf("weight_kg = $%d", argIdx)) + args = append(args, *req.WeightKG) + argIdx++ + } + if req.Age != nil { + setClauses = append(setClauses, fmt.Sprintf("age = $%d", argIdx)) + args = append(args, *req.Age) + argIdx++ + } + if req.Gender != nil { + setClauses = append(setClauses, fmt.Sprintf("gender = $%d", argIdx)) + args = append(args, *req.Gender) + argIdx++ + } + if req.Activity != nil { + setClauses = append(setClauses, fmt.Sprintf("activity = $%d", argIdx)) + args = append(args, *req.Activity) + argIdx++ + } + if req.Goal != nil { + setClauses = append(setClauses, fmt.Sprintf("goal = $%d", argIdx)) + args = append(args, *req.Goal) + argIdx++ + } + if req.Preferences != nil { + setClauses = append(setClauses, fmt.Sprintf("preferences = $%d", argIdx)) + args = append(args, string(*req.Preferences)) + argIdx++ + } + if req.DailyCalories != nil { + setClauses = append(setClauses, fmt.Sprintf("daily_calories = $%d", argIdx)) + args = append(args, *req.DailyCalories) + argIdx++ + } + + if len(setClauses) == 0 { + return "", nil + } + + setClauses = append(setClauses, "updated_at = now()") + + query := fmt.Sprintf(` + UPDATE users SET %s + WHERE id = $%d + RETURNING id, firebase_uid, email, name, avatar_url, + height_cm, weight_kg, age, gender, activity, goal, daily_calories, + plan, preferences, created_at, updated_at`, + strings.Join(setClauses, ", "), argIdx) + args = append(args, id) + + return query, args +} + +func (r *Repository) UpdateInTx(ctx context.Context, id string, profileReq UpdateProfileRequest, caloriesReq *UpdateProfileRequest) (*User, error) { + tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{}) + if err != nil { + return nil, fmt.Errorf("begin tx: %w", err) + } + defer tx.Rollback(ctx) + + // First update: profile fields + query, args := buildUpdateQuery(id, profileReq) + if query == "" { + return nil, fmt.Errorf("no fields to update") + } + updated, err := r.scanUser(tx.QueryRow(ctx, query, args...)) + if err != nil { + return nil, fmt.Errorf("update profile: %w", err) + } + + // Second update: calories (if provided) + if caloriesReq != nil { + query, args = buildUpdateQuery(id, *caloriesReq) + if query != "" { + updated, err = r.scanUser(tx.QueryRow(ctx, query, args...)) + if err != nil { + return nil, fmt.Errorf("update calories: %w", err) + } + } + } + + if err := tx.Commit(ctx); err != nil { + return nil, fmt.Errorf("commit tx: %w", err) + } + + return updated, nil +} + +func (r *Repository) SetRefreshToken(ctx context.Context, id, token string, expiresAt time.Time) error { + query := `UPDATE users SET refresh_token = $2, token_expires_at = $3, updated_at = now() WHERE id = $1` + _, err := r.pool.Exec(ctx, query, id, token, expiresAt) + return err +} + +func (r *Repository) FindByRefreshToken(ctx context.Context, token string) (*User, error) { + query := ` + SELECT id, firebase_uid, email, name, avatar_url, + height_cm, weight_kg, age, gender, activity, goal, daily_calories, + plan, preferences, created_at, updated_at + FROM users + WHERE refresh_token = $1 AND token_expires_at > now()` + + return r.scanUser(r.pool.QueryRow(ctx, query, token)) +} + +func (r *Repository) ClearRefreshToken(ctx context.Context, id string) error { + query := `UPDATE users SET refresh_token = NULL, token_expires_at = NULL, updated_at = now() WHERE id = $1` + _, err := r.pool.Exec(ctx, query, id) + return err +} + +type scannable interface { + Scan(dest ...interface{}) error +} + +func (r *Repository) scanUser(row scannable) (*User, error) { + var u User + var prefs []byte + err := row.Scan( + &u.ID, &u.FirebaseUID, &u.Email, &u.Name, &u.AvatarURL, + &u.HeightCM, &u.WeightKG, &u.Age, &u.Gender, &u.Activity, &u.Goal, &u.DailyCalories, + &u.Plan, &prefs, &u.CreatedAt, &u.UpdatedAt, + ) + if err != nil { + return nil, fmt.Errorf("scan user: %w", err) + } + u.Preferences = json.RawMessage(prefs) + return &u, nil +} diff --git a/backend/internal/user/repository_integration_test.go b/backend/internal/user/repository_integration_test.go new file mode 100644 index 0000000..209170c --- /dev/null +++ b/backend/internal/user/repository_integration_test.go @@ -0,0 +1,209 @@ +//go:build integration + +package user + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/food-ai/backend/internal/testutil" +) + +func TestRepository_UpsertByFirebaseUID_Insert(t *testing.T) { + pool := testutil.SetupTestDB(t) + repo := NewRepository(pool) + ctx := context.Background() + + u, err := repo.UpsertByFirebaseUID(ctx, "fb-123", "test@example.com", "Test User", "https://avatar.url") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if u.FirebaseUID != "fb-123" { + t.Errorf("expected fb-123, got %s", u.FirebaseUID) + } + if u.Email != "test@example.com" { + t.Errorf("expected test@example.com, got %s", u.Email) + } + if u.Plan != "free" { + t.Errorf("expected free, got %s", u.Plan) + } +} + +func TestRepository_UpsertByFirebaseUID_Update(t *testing.T) { + pool := testutil.SetupTestDB(t) + repo := NewRepository(pool) + ctx := context.Background() + + _, _ = repo.UpsertByFirebaseUID(ctx, "fb-123", "old@example.com", "Old Name", "") + u, err := repo.UpsertByFirebaseUID(ctx, "fb-123", "new@example.com", "New Name", "https://new-avatar.url") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if u.Email != "new@example.com" { + t.Errorf("expected new email, got %s", u.Email) + } + // Name should not be overwritten if already set + if u.Name != "Old Name" { + t.Errorf("expected name to be preserved as 'Old Name', got %s", u.Name) + } +} + +func TestRepository_GetByID(t *testing.T) { + pool := testutil.SetupTestDB(t) + repo := NewRepository(pool) + ctx := context.Background() + + created, _ := repo.UpsertByFirebaseUID(ctx, "fb-get", "get@example.com", "Get User", "") + u, err := repo.GetByID(ctx, created.ID) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if u.ID != created.ID { + t.Errorf("expected %s, got %s", created.ID, u.ID) + } +} + +func TestRepository_GetByID_NotFound(t *testing.T) { + pool := testutil.SetupTestDB(t) + repo := NewRepository(pool) + ctx := context.Background() + + _, err := repo.GetByID(ctx, "00000000-0000-0000-0000-000000000000") + if err == nil { + t.Fatal("expected error for non-existent user") + } +} + +func TestRepository_Update(t *testing.T) { + pool := testutil.SetupTestDB(t) + repo := NewRepository(pool) + ctx := context.Background() + + created, _ := repo.UpsertByFirebaseUID(ctx, "fb-upd", "upd@example.com", "Update User", "") + height := 180 + u, err := repo.Update(ctx, created.ID, UpdateProfileRequest{HeightCM: &height}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if u.HeightCM == nil || *u.HeightCM != 180 { + t.Errorf("expected height 180, got %v", u.HeightCM) + } +} + +func TestRepository_Update_MultipleFields(t *testing.T) { + pool := testutil.SetupTestDB(t) + repo := NewRepository(pool) + ctx := context.Background() + + created, _ := repo.UpsertByFirebaseUID(ctx, "fb-multi", "multi@example.com", "Multi User", "") + height := 175 + weight := 70.5 + age := 25 + gender := "male" + activity := "moderate" + goal := "maintain" + u, err := repo.Update(ctx, created.ID, UpdateProfileRequest{ + HeightCM: &height, + WeightKG: &weight, + Age: &age, + Gender: &gender, + Activity: &activity, + Goal: &goal, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if u.HeightCM == nil || *u.HeightCM != 175 { + t.Errorf("expected 175, got %v", u.HeightCM) + } + if u.WeightKG == nil || *u.WeightKG != 70.5 { + t.Errorf("expected 70.5, got %v", u.WeightKG) + } +} + +func TestRepository_Update_Preferences(t *testing.T) { + pool := testutil.SetupTestDB(t) + repo := NewRepository(pool) + ctx := context.Background() + + created, _ := repo.UpsertByFirebaseUID(ctx, "fb-prefs", "prefs@example.com", "Prefs User", "") + prefs := json.RawMessage(`{"cuisines":["russian","asian"]}`) + u, err := repo.Update(ctx, created.ID, UpdateProfileRequest{Preferences: &prefs}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + var p map[string]interface{} + json.Unmarshal(u.Preferences, &p) + if p["cuisines"] == nil { + t.Error("expected cuisines in preferences") + } +} + +func TestRepository_SetAndFindRefreshToken(t *testing.T) { + pool := testutil.SetupTestDB(t) + repo := NewRepository(pool) + ctx := context.Background() + + created, _ := repo.UpsertByFirebaseUID(ctx, "fb-token", "token@example.com", "Token User", "") + err := repo.SetRefreshToken(ctx, created.ID, "refresh-token-123", time.Now().Add(24*time.Hour)) + if err != nil { + t.Fatalf("unexpected error setting token: %v", err) + } + + u, err := repo.FindByRefreshToken(ctx, "refresh-token-123") + if err != nil { + t.Fatalf("unexpected error finding by token: %v", err) + } + if u.ID != created.ID { + t.Errorf("expected %s, got %s", created.ID, u.ID) + } +} + +func TestRepository_FindByRefreshToken_Expired(t *testing.T) { + pool := testutil.SetupTestDB(t) + repo := NewRepository(pool) + ctx := context.Background() + + created, _ := repo.UpsertByFirebaseUID(ctx, "fb-expired", "expired@example.com", "Expired User", "") + _ = repo.SetRefreshToken(ctx, created.ID, "expired-token", time.Now().Add(-1*time.Hour)) + + _, err := repo.FindByRefreshToken(ctx, "expired-token") + if err == nil { + t.Fatal("expected error for expired token") + } +} + +func TestRepository_ClearRefreshToken(t *testing.T) { + pool := testutil.SetupTestDB(t) + repo := NewRepository(pool) + ctx := context.Background() + + created, _ := repo.UpsertByFirebaseUID(ctx, "fb-clear", "clear@example.com", "Clear User", "") + _ = repo.SetRefreshToken(ctx, created.ID, "token-to-clear", time.Now().Add(24*time.Hour)) + err := repo.ClearRefreshToken(ctx, created.ID) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + _, err = repo.FindByRefreshToken(ctx, "token-to-clear") + if err == nil { + t.Fatal("expected error after clearing token") + } +} + +func TestRepository_Update_NoFields(t *testing.T) { + pool := testutil.SetupTestDB(t) + repo := NewRepository(pool) + ctx := context.Background() + + created, _ := repo.UpsertByFirebaseUID(ctx, "fb-noop", "noop@example.com", "Noop User", "") + u, err := repo.Update(ctx, created.ID, UpdateProfileRequest{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if u.ID != created.ID { + t.Errorf("expected %s, got %s", created.ID, u.ID) + } +} diff --git a/backend/internal/user/service.go b/backend/internal/user/service.go new file mode 100644 index 0000000..d52c30e --- /dev/null +++ b/backend/internal/user/service.go @@ -0,0 +1,117 @@ +package user + +import ( + "context" + "fmt" +) + +type Service struct { + repo UserRepository +} + +func NewService(repo UserRepository) *Service { + return &Service{repo: repo} +} + +func (s *Service) GetProfile(ctx context.Context, userID string) (*User, error) { + return s.repo.GetByID(ctx, userID) +} + +func (s *Service) UpdateProfile(ctx context.Context, userID string, req UpdateProfileRequest) (*User, error) { + if err := validateProfileRequest(req); err != nil { + return nil, err + } + + if !req.HasBodyParams() { + updated, err := s.repo.Update(ctx, userID, req) + if err != nil { + return nil, fmt.Errorf("update profile: %w", err) + } + return updated, nil + } + + // Need to update profile + recalculate calories in a single transaction. + // First, get current user to merge with incoming fields for calorie calculation. + current, err := s.repo.GetByID(ctx, userID) + if err != nil { + return nil, fmt.Errorf("get user: %w", err) + } + + // Merge current values with request to compute calories + height := current.HeightCM + if req.HeightCM != nil { + height = req.HeightCM + } + weight := current.WeightKG + if req.WeightKG != nil { + weight = req.WeightKG + } + age := current.Age + if req.Age != nil { + age = req.Age + } + gender := current.Gender + if req.Gender != nil { + gender = req.Gender + } + activity := current.Activity + if req.Activity != nil { + activity = req.Activity + } + goal := current.Goal + if req.Goal != nil { + goal = req.Goal + } + + calories := CalculateDailyCalories(height, weight, age, gender, activity, goal) + + var calReq *UpdateProfileRequest + if calories != nil { + calReq = &UpdateProfileRequest{DailyCalories: calories} + } + + updated, err := s.repo.UpdateInTx(ctx, userID, req, calReq) + if err != nil { + return nil, fmt.Errorf("update profile: %w", err) + } + + return updated, nil +} + +func validateProfileRequest(req UpdateProfileRequest) error { + if req.HeightCM != nil { + if *req.HeightCM < 100 || *req.HeightCM > 250 { + return fmt.Errorf("height_cm must be between 100 and 250") + } + } + if req.WeightKG != nil { + if *req.WeightKG < 30 || *req.WeightKG > 300 { + return fmt.Errorf("weight_kg must be between 30 and 300") + } + } + if req.Age != nil { + if *req.Age < 10 || *req.Age > 120 { + return fmt.Errorf("age must be between 10 and 120") + } + } + if req.Gender != nil { + if *req.Gender != "male" && *req.Gender != "female" { + return fmt.Errorf("gender must be 'male' or 'female'") + } + } + if req.Activity != nil { + switch *req.Activity { + case "low", "moderate", "high": + default: + return fmt.Errorf("activity must be 'low', 'moderate', or 'high'") + } + } + if req.Goal != nil { + switch *req.Goal { + case "lose", "maintain", "gain": + default: + return fmt.Errorf("goal must be 'lose', 'maintain', or 'gain'") + } + } + return nil +} diff --git a/backend/internal/user/service_test.go b/backend/internal/user/service_test.go new file mode 100644 index 0000000..8e76e6a --- /dev/null +++ b/backend/internal/user/service_test.go @@ -0,0 +1,247 @@ +package user + +import ( + "context" + "encoding/json" + "fmt" + "testing" + "time" +) + +func ptrStr(s string) *string { return &s } +func ptrInt(i int) *int { return &i } +func ptrFloat(f float64) *float64 { return &f } + +// mockUserRepo is an in-package mock to avoid import cycles. +type mockUserRepo struct { + getByIDFn func(ctx context.Context, id string) (*User, error) + updateFn func(ctx context.Context, id string, req UpdateProfileRequest) (*User, error) + updateInTxFn func(ctx context.Context, id string, profileReq UpdateProfileRequest, caloriesReq *UpdateProfileRequest) (*User, error) + upsertByFirebaseUIDFn func(ctx context.Context, uid, email, name, avatarURL string) (*User, error) + setRefreshTokenFn func(ctx context.Context, id, token string, expiresAt time.Time) error + findByRefreshTokenFn func(ctx context.Context, token string) (*User, error) + clearRefreshTokenFn func(ctx context.Context, id string) error +} + +func (m *mockUserRepo) UpsertByFirebaseUID(ctx context.Context, uid, email, name, avatarURL string) (*User, error) { + return m.upsertByFirebaseUIDFn(ctx, uid, email, name, avatarURL) +} +func (m *mockUserRepo) GetByID(ctx context.Context, id string) (*User, error) { + return m.getByIDFn(ctx, id) +} +func (m *mockUserRepo) Update(ctx context.Context, id string, req UpdateProfileRequest) (*User, error) { + return m.updateFn(ctx, id, req) +} +func (m *mockUserRepo) UpdateInTx(ctx context.Context, id string, profileReq UpdateProfileRequest, caloriesReq *UpdateProfileRequest) (*User, error) { + return m.updateInTxFn(ctx, id, profileReq, caloriesReq) +} +func (m *mockUserRepo) SetRefreshToken(ctx context.Context, id, token string, expiresAt time.Time) error { + return m.setRefreshTokenFn(ctx, id, token, expiresAt) +} +func (m *mockUserRepo) FindByRefreshToken(ctx context.Context, token string) (*User, error) { + return m.findByRefreshTokenFn(ctx, token) +} +func (m *mockUserRepo) ClearRefreshToken(ctx context.Context, id string) error { + return m.clearRefreshTokenFn(ctx, id) +} + +func TestGetProfile_Success(t *testing.T) { + repo := &mockUserRepo{ + getByIDFn: func(ctx context.Context, id string) (*User, error) { + return &User{ID: id, Email: "test@example.com", Plan: "free"}, nil + }, + } + svc := NewService(repo) + + u, err := svc.GetProfile(context.Background(), "user-1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if u.ID != "user-1" { + t.Errorf("expected user-1, got %s", u.ID) + } +} + +func TestGetProfile_NotFound(t *testing.T) { + repo := &mockUserRepo{ + getByIDFn: func(ctx context.Context, id string) (*User, error) { + return nil, fmt.Errorf("not found") + }, + } + svc := NewService(repo) + + _, err := svc.GetProfile(context.Background(), "nonexistent") + if err == nil { + t.Fatal("expected error") + } +} + +func TestUpdateProfile_NameOnly(t *testing.T) { + repo := &mockUserRepo{ + updateFn: func(ctx context.Context, id string, req UpdateProfileRequest) (*User, error) { + return &User{ID: id, Name: *req.Name, Plan: "free"}, nil + }, + } + svc := NewService(repo) + + u, err := svc.UpdateProfile(context.Background(), "user-1", UpdateProfileRequest{Name: ptrStr("New Name")}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if u.Name != "New Name" { + t.Errorf("expected 'New Name', got %q", u.Name) + } +} + +func TestUpdateProfile_WithCaloriesRecalculation(t *testing.T) { + profileReq := UpdateProfileRequest{ + HeightCM: ptrInt(180), + WeightKG: ptrFloat(80), + Age: ptrInt(30), + Gender: ptrStr("male"), + Activity: ptrStr("moderate"), + Goal: ptrStr("maintain"), + } + finalUser := &User{ + ID: "user-1", + HeightCM: ptrInt(180), + WeightKG: ptrFloat(80), + Age: ptrInt(30), + Gender: ptrStr("male"), + Activity: ptrStr("moderate"), + Goal: ptrStr("maintain"), + DailyCalories: ptrInt(2759), + Plan: "free", + } + + repo := &mockUserRepo{ + // Service calls GetByID first to merge current values for calorie calculation + getByIDFn: func(ctx context.Context, id string) (*User, error) { + return &User{ID: id, Plan: "free"}, nil + }, + updateInTxFn: func(ctx context.Context, id string, pReq UpdateProfileRequest, cReq *UpdateProfileRequest) (*User, error) { + if cReq == nil { + t.Error("expected caloriesReq to be non-nil") + } else if *cReq.DailyCalories != 2759 { + t.Errorf("expected calories 2759, got %d", *cReq.DailyCalories) + } + return finalUser, nil + }, + } + svc := NewService(repo) + + u, err := svc.UpdateProfile(context.Background(), "user-1", profileReq) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if u.DailyCalories == nil { + t.Fatal("expected daily_calories to be set") + } + if *u.DailyCalories != 2759 { + t.Errorf("expected 2759, got %d", *u.DailyCalories) + } +} + +func TestUpdateProfile_InvalidHeight_TooLow(t *testing.T) { + svc := NewService(&mockUserRepo{}) + + _, err := svc.UpdateProfile(context.Background(), "user-1", UpdateProfileRequest{HeightCM: ptrInt(50)}) + if err == nil { + t.Fatal("expected validation error") + } +} + +func TestUpdateProfile_InvalidHeight_TooHigh(t *testing.T) { + svc := NewService(&mockUserRepo{}) + + _, err := svc.UpdateProfile(context.Background(), "user-1", UpdateProfileRequest{HeightCM: ptrInt(300)}) + if err == nil { + t.Fatal("expected validation error") + } +} + +func TestUpdateProfile_InvalidWeight_TooLow(t *testing.T) { + svc := NewService(&mockUserRepo{}) + + _, err := svc.UpdateProfile(context.Background(), "user-1", UpdateProfileRequest{WeightKG: ptrFloat(10)}) + if err == nil { + t.Fatal("expected validation error") + } +} + +func TestUpdateProfile_InvalidWeight_TooHigh(t *testing.T) { + svc := NewService(&mockUserRepo{}) + + _, err := svc.UpdateProfile(context.Background(), "user-1", UpdateProfileRequest{WeightKG: ptrFloat(400)}) + if err == nil { + t.Fatal("expected validation error") + } +} + +func TestUpdateProfile_InvalidAge_TooLow(t *testing.T) { + svc := NewService(&mockUserRepo{}) + + _, err := svc.UpdateProfile(context.Background(), "user-1", UpdateProfileRequest{Age: ptrInt(5)}) + if err == nil { + t.Fatal("expected validation error") + } +} + +func TestUpdateProfile_InvalidAge_TooHigh(t *testing.T) { + svc := NewService(&mockUserRepo{}) + + _, err := svc.UpdateProfile(context.Background(), "user-1", UpdateProfileRequest{Age: ptrInt(150)}) + if err == nil { + t.Fatal("expected validation error") + } +} + +func TestUpdateProfile_InvalidGender(t *testing.T) { + svc := NewService(&mockUserRepo{}) + + _, err := svc.UpdateProfile(context.Background(), "user-1", UpdateProfileRequest{Gender: ptrStr("other")}) + if err == nil { + t.Fatal("expected validation error") + } +} + +func TestUpdateProfile_InvalidActivity(t *testing.T) { + svc := NewService(&mockUserRepo{}) + + _, err := svc.UpdateProfile(context.Background(), "user-1", UpdateProfileRequest{Activity: ptrStr("extreme")}) + if err == nil { + t.Fatal("expected validation error") + } +} + +func TestUpdateProfile_InvalidGoal(t *testing.T) { + svc := NewService(&mockUserRepo{}) + + _, err := svc.UpdateProfile(context.Background(), "user-1", UpdateProfileRequest{Goal: ptrStr("bulk")}) + if err == nil { + t.Fatal("expected validation error") + } +} + +func TestUpdateProfile_Preferences(t *testing.T) { + prefs := json.RawMessage(`{"cuisines":["russian"]}`) + repo := &mockUserRepo{ + updateFn: func(ctx context.Context, id string, req UpdateProfileRequest) (*User, error) { + return &User{ + ID: id, + Plan: "free", + Preferences: *req.Preferences, + UpdatedAt: time.Now(), + }, nil + }, + } + svc := NewService(repo) + + u, err := svc.UpdateProfile(context.Background(), "user-1", UpdateProfileRequest{Preferences: &prefs}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(u.Preferences) != `{"cuisines":["russian"]}` { + t.Errorf("unexpected preferences: %s", u.Preferences) + } +} diff --git a/backend/migrations/001_create_users.sql b/backend/migrations/001_create_users.sql new file mode 100644 index 0000000..16646e1 --- /dev/null +++ b/backend/migrations/001_create_users.sql @@ -0,0 +1,49 @@ +-- +goose Up +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +CREATE TYPE user_plan AS ENUM ('free', 'paid'); +CREATE TYPE user_gender AS ENUM ('male', 'female'); +CREATE TYPE user_goal AS ENUM ('lose', 'maintain', 'gain'); +CREATE TYPE activity_level AS ENUM ('low', 'moderate', 'high'); + +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + firebase_uid VARCHAR(128) NOT NULL UNIQUE, + email VARCHAR(255) NOT NULL, + name VARCHAR(255) NOT NULL DEFAULT '', + avatar_url TEXT, + + -- Body parameters + height_cm SMALLINT, + weight_kg DECIMAL(5,2), + age SMALLINT, + gender user_gender, + activity activity_level, + + -- Goal and calculated daily norm + goal user_goal, + daily_calories INTEGER, + + -- Plan + plan user_plan NOT NULL DEFAULT 'free', + + -- Preferences (JSONB for flexibility) + preferences JSONB NOT NULL DEFAULT '{}'::jsonb, + + -- Refresh token + refresh_token TEXT, + token_expires_at TIMESTAMPTZ, + + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_users_firebase_uid ON users (firebase_uid); +CREATE INDEX idx_users_email ON users (email); + +-- +goose Down +DROP TABLE IF EXISTS users; +DROP TYPE IF EXISTS activity_level; +DROP TYPE IF EXISTS user_goal; +DROP TYPE IF EXISTS user_gender; +DROP TYPE IF EXISTS user_plan; diff --git a/client/.gitignore b/client/.gitignore new file mode 100644 index 0000000..3820a95 --- /dev/null +++ b/client/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/client/.metadata b/client/.metadata new file mode 100644 index 0000000..c4525e3 --- /dev/null +++ b/client/.metadata @@ -0,0 +1,36 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "a402d9a4376add5bc2d6b1e33e53edaae58c07f8" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: a402d9a4376add5bc2d6b1e33e53edaae58c07f8 + base_revision: a402d9a4376add5bc2d6b1e33e53edaae58c07f8 + - platform: android + create_revision: a402d9a4376add5bc2d6b1e33e53edaae58c07f8 + base_revision: a402d9a4376add5bc2d6b1e33e53edaae58c07f8 + - platform: ios + create_revision: a402d9a4376add5bc2d6b1e33e53edaae58c07f8 + base_revision: a402d9a4376add5bc2d6b1e33e53edaae58c07f8 + - platform: web + create_revision: a402d9a4376add5bc2d6b1e33e53edaae58c07f8 + base_revision: a402d9a4376add5bc2d6b1e33e53edaae58c07f8 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/client/README.md b/client/README.md new file mode 100644 index 0000000..a2165f2 --- /dev/null +++ b/client/README.md @@ -0,0 +1,151 @@ +# FoodAI Client + +Flutter-приложение для управления питанием с поддержкой iOS, Android и Web. + +## Стек + +- **Flutter 3 / Dart 3** — фреймворк +- **Riverpod** — управление состоянием +- **go_router** — навигация с auth guard +- **Dio** — HTTP-клиент с автоматическим обновлением токенов +- **Firebase Auth** — аутентификация (email + Google) +- **flutter_secure_storage** — безопасное хранение токенов +- **json_serializable** — генерация сериализации моделей + +## Требования + +- Flutter SDK 3.x (`flutter --version`) +- Dart SDK 3.9+ +- Android Studio или Xcode (для запуска на симуляторе/устройстве) +- Chrome (для запуска в браузере) +- Проект Firebase с включёнными Email и Google Sign-In + +## Быстрый старт + +### 1. Установить зависимости + +```bash +flutter pub get +``` + +### 2. Настроить Firebase + +1. Создайте проект в [Firebase Console](https://console.firebase.google.com) +2. Включите **Authentication → Sign-in method**: Email/Password и Google + +#### Android + +1. Project Settings (⚙️) → **Your apps** → иконка Android (`🤖`) +2. Package name: `com.foodai.food_ai` +3. Скачать `google-services.json` → положить в `android/app/`: +```bash +cp ~/Downloads/google-services.json android/app/google-services.json +``` + +#### iOS + +1. Project Settings (⚙️) → **Your apps** → иконка iOS (``) +2. Bundle ID: `com.foodai.foodAi` +3. Скачать `GoogleService-Info.plist` → добавить через Xcode в группу `Runner`: +```bash +open ios/Runner.xcworkspace +# Перетащить GoogleService-Info.plist в группу Runner в навигаторе Xcode +``` + +#### Web + +1. Project Settings (⚙️) → **Your apps** → иконка Web (``) +2. Nickname: `FoodAI Web` +3. Скопировать полученный `firebaseConfig` в `web/index.html`, заменив `YOUR_*` placeholder'ы +4. Убедиться, что `localhost` есть в списке авторизованных доменов: + Authentication → **Settings** → **Authorized domains** + +> **Важно:** не коммитить конфиг-файлы в git: +> ```bash +> echo "android/app/google-services.json" >> .gitignore +> echo "ios/Runner/GoogleService-Info.plist" >> .gitignore +> ``` + +### 3. Настроить URL бэкенда + +Откройте `lib/core/config/app_config.dart` и укажите адрес API: + +```dart +static const development = AppConfig( + apiBaseUrl: 'http://10.0.2.2:9090', // Android-эмулятор → localhost:9090 + // apiBaseUrl: 'http://localhost:9090', // iOS-симулятор / Web +); + +static const production = AppConfig( + apiBaseUrl: 'https://api.food-ai.app', +); +``` + +> Бэкенд по умолчанию поднимается на порту `9090` через Docker Compose (`9090:8080`). + +### 4. Сгенерировать код + +```bash +flutter pub run build_runner build --delete-conflicting-outputs +``` + +### 5. Запустить приложение + +```bash +flutter run +``` + +## Команды + +| Команда | Описание | +|---|---| +| `flutter pub get` | Установить зависимости | +| `flutter run` | Запустить на подключённом устройстве/симуляторе | +| `flutter run -d chrome` | Запустить в браузере (web) | +| `flutter test` | Запустить все тесты | +| `flutter analyze` | Статический анализ кода | +| `flutter build apk` | Собрать APK для Android | +| `flutter build ios` | Собрать для iOS | +| `flutter pub run build_runner build` | Сгенерировать код (json_serializable) | +| `flutter pub run build_runner watch` | Следить за изменениями и генерировать | + +## Структура проекта + +``` +client/ +├── lib/ +│ ├── core/ +│ │ ├── api/ # ApiClient (Dio), AuthInterceptor, исключения +│ │ ├── auth/ # AuthService, AuthNotifier (Riverpod), SecureStorage +│ │ ├── config/ # AppConfig (URL бэкенда по окружению) +│ │ ├── router/ # GoRouter с auth guard и MainShell +│ │ └── theme/ # Цвета и тема Material 3 +│ ├── features/ +│ │ ├── auth/ # LoginScreen, RegisterScreen +│ │ ├── home/ # HomeScreen (placeholder) +│ │ ├── menu/ # MenuScreen (placeholder) +│ │ ├── products/ # ProductsScreen (placeholder) +│ │ ├── profile/ # ProfileScreen (placeholder) +│ │ └── recipes/ # RecipesScreen (placeholder) +│ ├── shared/ +│ │ └── models/ # User (json_serializable + user.g.dart) +│ ├── app.dart # Корневой виджет +│ └── main.dart # Точка входа, инициализация Firebase +└── test/ + ├── features/auth/ # Тесты LoginScreen, RegisterScreen + └── shared/models/ # Тесты модели User +``` + +## Навигация + +Приложение использует `go_router` с guard-ом авторизации: + +- `/login` — экран входа (только для неавторизованных) +- `/register` — экран регистрации +- `/` → `/home` — главная (требует авторизации) +- `/products` — продукты +- `/menu` — меню дня +- `/recipes` — рецепты +- `/profile` — профиль пользователя + +Неавторизованный пользователь автоматически перенаправляется на `/login`. После входа — на `/home`. diff --git a/client/analysis_options.yaml b/client/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/client/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/client/android/.gitignore b/client/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/client/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/client/android/app/build.gradle.kts b/client/android/app/build.gradle.kts new file mode 100644 index 0000000..9129695 --- /dev/null +++ b/client/android/app/build.gradle.kts @@ -0,0 +1,44 @@ +plugins { + id("com.android.application") + id("kotlin-android") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.foodai.food_ai" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_11.toString() + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.foodai.food_ai" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +flutter { + source = "../.." +} diff --git a/client/android/app/src/debug/AndroidManifest.xml b/client/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/client/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/client/android/app/src/main/AndroidManifest.xml b/client/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..e1698c9 --- /dev/null +++ b/client/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/client/android/app/src/main/kotlin/com/foodai/food_ai/MainActivity.kt b/client/android/app/src/main/kotlin/com/foodai/food_ai/MainActivity.kt new file mode 100644 index 0000000..0bce6ae --- /dev/null +++ b/client/android/app/src/main/kotlin/com/foodai/food_ai/MainActivity.kt @@ -0,0 +1,5 @@ +package com.foodai.food_ai + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/client/android/app/src/main/res/drawable-v21/launch_background.xml b/client/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/client/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/client/android/app/src/main/res/drawable/launch_background.xml b/client/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/client/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/client/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/client/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..db77bb4 Binary files /dev/null and b/client/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/client/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/client/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..17987b7 Binary files /dev/null and b/client/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/client/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/client/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/client/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/client/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/client/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d5f1c8d Binary files /dev/null and b/client/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/client/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/client/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4d6372e Binary files /dev/null and b/client/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/client/android/app/src/main/res/values-night/styles.xml b/client/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/client/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/client/android/app/src/main/res/values/styles.xml b/client/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/client/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/client/android/app/src/profile/AndroidManifest.xml b/client/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/client/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/client/android/build.gradle.kts b/client/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/client/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/client/android/gradle.properties b/client/android/gradle.properties new file mode 100644 index 0000000..f018a61 --- /dev/null +++ b/client/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +android.enableJetifier=true diff --git a/client/android/gradle/wrapper/gradle-wrapper.properties b/client/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..ac3b479 --- /dev/null +++ b/client/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip diff --git a/client/android/settings.gradle.kts b/client/android/settings.gradle.kts new file mode 100644 index 0000000..fb605bc --- /dev/null +++ b/client/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "8.9.1" apply false + id("org.jetbrains.kotlin.android") version "2.1.0" apply false +} + +include(":app") diff --git a/client/ios/.gitignore b/client/ios/.gitignore new file mode 100644 index 0000000..7a7f987 --- /dev/null +++ b/client/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/client/ios/Flutter/AppFrameworkInfo.plist b/client/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..1dc6cf7 --- /dev/null +++ b/client/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 13.0 + + diff --git a/client/ios/Flutter/Debug.xcconfig b/client/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/client/ios/Flutter/Debug.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/client/ios/Flutter/Release.xcconfig b/client/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/client/ios/Flutter/Release.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/client/ios/Runner.xcodeproj/project.pbxproj b/client/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..c66db46 --- /dev/null +++ b/client/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,616 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.foodai.foodAi; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.foodai.foodAi.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.foodai.foodAi.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.foodai.foodAi.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.foodai.foodAi; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.foodai.foodAi; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/client/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/client/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/client/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/client/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/client/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/client/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/client/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/client/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/client/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/client/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/client/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..e3773d4 --- /dev/null +++ b/client/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/ios/Runner.xcworkspace/contents.xcworkspacedata b/client/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/client/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/client/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/client/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/client/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/client/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/client/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/client/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/client/ios/Runner/AppDelegate.swift b/client/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..6266644 --- /dev/null +++ b/client/ios/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + GeneratedPluginRegistrant.register(with: self) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} diff --git a/client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d36b1fa --- /dev/null +++ b/client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000..dc9ada4 Binary files /dev/null and b/client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000..7353c41 Binary files /dev/null and b/client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000..6ed2d93 Binary files /dev/null and b/client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000..4cd7b00 Binary files /dev/null and b/client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000..fe73094 Binary files /dev/null and b/client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000..321773c Binary files /dev/null and b/client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000..502f463 Binary files /dev/null and b/client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000..e9f5fea Binary files /dev/null and b/client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000..84ac32a Binary files /dev/null and b/client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000..8953cba Binary files /dev/null and b/client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000..0467bf1 Binary files /dev/null and b/client/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/client/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/client/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..0bedcf2 --- /dev/null +++ b/client/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/client/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/client/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/client/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/client/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/client/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/client/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/client/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/client/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/client/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/client/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/client/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/client/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/client/ios/Runner/Base.lproj/LaunchScreen.storyboard b/client/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..f2e259c --- /dev/null +++ b/client/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/ios/Runner/Base.lproj/Main.storyboard b/client/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/client/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/ios/Runner/Info.plist b/client/ios/Runner/Info.plist new file mode 100644 index 0000000..1a920f4 --- /dev/null +++ b/client/ios/Runner/Info.plist @@ -0,0 +1,49 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Food Ai + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + food_ai + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + CADisableMinimumFrameDurationOnPhone + + UIApplicationSupportsIndirectInputEvents + + + diff --git a/client/ios/Runner/Runner-Bridging-Header.h b/client/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/client/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/client/ios/RunnerTests/RunnerTests.swift b/client/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..86a7c3b --- /dev/null +++ b/client/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/client/lib/app.dart b/client/lib/app.dart new file mode 100644 index 0000000..769bdb7 --- /dev/null +++ b/client/lib/app.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'core/router/app_router.dart'; +import 'core/theme/app_theme.dart'; + +class App extends ConsumerWidget { + const App({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final router = ref.watch(routerProvider); + + return MaterialApp.router( + title: 'FoodAI', + theme: appTheme(), + routerConfig: router, + debugShowCheckedModeBanner: false, + ); + } +} diff --git a/client/lib/core/api/api_client.dart b/client/lib/core/api/api_client.dart new file mode 100644 index 0000000..ef43164 --- /dev/null +++ b/client/lib/core/api/api_client.dart @@ -0,0 +1,45 @@ +import 'package:dio/dio.dart'; +import '../auth/secure_storage.dart'; +import 'auth_interceptor.dart'; + +class ApiClient { + late final Dio _dio; + + ApiClient({required String baseUrl, required SecureStorageService storage}) { + _dio = Dio(BaseOptions( + baseUrl: baseUrl, + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 30), + headers: {'Content-Type': 'application/json'}, + )); + + _dio.interceptors.addAll([ + AuthInterceptor(storage: storage, dio: _dio), + LogInterceptor(requestBody: true, responseBody: true), + ]); + } + + /// Exposed for testing only. + ApiClient.withDio(this._dio); + + Future> get(String path, + {Map? params}) async { + final response = await _dio.get(path, queryParameters: params); + return response.data; + } + + Future> post(String path, {dynamic data}) async { + final response = await _dio.post(path, data: data); + return response.data; + } + + Future> put(String path, {dynamic data}) async { + final response = await _dio.put(path, data: data); + return response.data; + } + + Future> delete(String path) async { + final response = await _dio.delete(path); + return response.data; + } +} diff --git a/client/lib/core/api/api_exceptions.dart b/client/lib/core/api/api_exceptions.dart new file mode 100644 index 0000000..53d195d --- /dev/null +++ b/client/lib/core/api/api_exceptions.dart @@ -0,0 +1,22 @@ +class ApiException implements Exception { + final String message; + final int? statusCode; + + const ApiException(this.message, {this.statusCode}); + + @override + String toString() => 'ApiException($statusCode): $message'; +} + +class UnauthorizedException extends ApiException { + const UnauthorizedException([super.message = 'Unauthorized']) + : super(statusCode: 401); +} + +class BadRequestException extends ApiException { + const BadRequestException(super.message) : super(statusCode: 400); +} + +class NetworkException extends ApiException { + const NetworkException([super.message = 'No internet connection']); +} diff --git a/client/lib/core/api/auth_interceptor.dart b/client/lib/core/api/auth_interceptor.dart new file mode 100644 index 0000000..4cd43c7 --- /dev/null +++ b/client/lib/core/api/auth_interceptor.dart @@ -0,0 +1,94 @@ +import 'package:dio/dio.dart'; +import '../auth/secure_storage.dart'; + +class AuthInterceptor extends Interceptor { + final SecureStorageService _storage; + final Dio _dio; + + // Prevents multiple simultaneous token refresh requests + bool _isRefreshing = false; + final List<({RequestOptions options, ErrorInterceptorHandler handler})> + _pendingRequests = []; + + AuthInterceptor({required SecureStorageService storage, required Dio dio}) + : _storage = storage, + _dio = dio; + + @override + Future onRequest( + RequestOptions options, + RequestInterceptorHandler handler, + ) async { + if (options.path.startsWith('/auth/')) { + return handler.next(options); + } + + final token = await _storage.getAccessToken(); + if (token != null) { + options.headers['Authorization'] = 'Bearer $token'; + } + handler.next(options); + } + + @override + Future onError( + DioException err, + ErrorInterceptorHandler handler, + ) async { + if (err.response?.statusCode != 401) { + return handler.next(err); + } + + final refreshToken = await _storage.getRefreshToken(); + if (refreshToken == null) { + return handler.next(err); + } + + // If a refresh is already in progress, queue this request + if (_isRefreshing) { + _pendingRequests.add((options: err.requestOptions, handler: handler)); + return; + } + + _isRefreshing = true; + try { + final response = await _dio.post('/auth/refresh', data: { + 'refresh_token': refreshToken, + }); + + final newAccessToken = response.data['access_token'] as String; + final newRefreshToken = response.data['refresh_token'] as String; + + await _storage.saveTokens( + accessToken: newAccessToken, + refreshToken: newRefreshToken, + ); + + // Retry the original request + final retryOptions = err.requestOptions; + retryOptions.headers['Authorization'] = 'Bearer $newAccessToken'; + final retryResponse = await _dio.fetch(retryOptions); + handler.resolve(retryResponse); + + // Retry all pending requests with the new token + for (final pending in _pendingRequests) { + pending.options.headers['Authorization'] = 'Bearer $newAccessToken'; + try { + final r = await _dio.fetch(pending.options); + pending.handler.resolve(r); + } catch (e) { + pending.handler.next(err); + } + } + } catch (_) { + await _storage.clearTokens(); + handler.next(err); + for (final pending in _pendingRequests) { + pending.handler.next(err); + } + } finally { + _isRefreshing = false; + _pendingRequests.clear(); + } + } +} diff --git a/client/lib/core/auth/auth_provider.dart b/client/lib/core/auth/auth_provider.dart new file mode 100644 index 0000000..3cd2548 --- /dev/null +++ b/client/lib/core/auth/auth_provider.dart @@ -0,0 +1,160 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../shared/models/user.dart'; +import '../api/api_client.dart'; +import '../config/app_config.dart'; +import 'auth_service.dart'; +import 'secure_storage.dart'; +import 'package:firebase_auth/firebase_auth.dart' as fb; + +enum AuthStatus { unknown, authenticated, unauthenticated } + +class AuthState { + final AuthStatus status; + final User? user; + final bool isLoading; + final String? error; + + const AuthState({ + this.status = AuthStatus.unknown, + this.user, + this.isLoading = false, + this.error, + }); + + AuthState copyWith({ + AuthStatus? status, + User? user, + bool? isLoading, + String? error, + }) { + return AuthState( + status: status ?? this.status, + user: user ?? this.user, + isLoading: isLoading ?? this.isLoading, + error: error, + ); + } +} + +class AuthNotifier extends StateNotifier { + final AuthService? _authService; + + AuthNotifier(AuthService authService) : _authService = authService, super(const AuthState()) { + _checkAuth(); + } + + @visibleForTesting + AuthNotifier.test(super.initialState) + : _authService = null; + + Future _checkAuth() async { + final isAuth = await _authService!.isAuthenticated(); + state = state.copyWith( + status: isAuth ? AuthStatus.authenticated : AuthStatus.unauthenticated, + ); + } + + Future signInWithEmail(String email, String password) async { + state = state.copyWith(isLoading: true, error: null); + try { + final user = await _authService!.signInWithEmail(email, password); + state = AuthState( + status: AuthStatus.authenticated, + user: user, + ); + } catch (e) { + state = state.copyWith( + isLoading: false, + error: _mapError(e), + ); + } + } + + Future signInWithGoogle() async { + state = state.copyWith(isLoading: true, error: null); + try { + final user = await _authService!.signInWithGoogle(); + state = AuthState( + status: AuthStatus.authenticated, + user: user, + ); + } catch (e) { + state = state.copyWith( + isLoading: false, + error: _mapError(e), + ); + } + } + + Future register(String email, String password, String name) async { + state = state.copyWith(isLoading: true, error: null); + try { + final user = + await _authService!.registerWithEmail(email, password, name); + state = AuthState( + status: AuthStatus.authenticated, + user: user, + ); + } catch (e) { + state = state.copyWith( + isLoading: false, + error: _mapError(e), + ); + } + } + + Future signOut() async { + await _authService!.signOut(); + state = const AuthState(status: AuthStatus.unauthenticated); + } + + String _mapError(dynamic e) { + if (e is fb.FirebaseAuthException) { + switch (e.code) { + case 'wrong-password': + case 'user-not-found': + return 'Неверный email или пароль'; + case 'email-already-in-use': + return 'Email уже зарегистрирован'; + case 'weak-password': + return 'Пароль слишком простой'; + case 'invalid-email': + return 'Некорректный email'; + default: + return 'Ошибка авторизации: ${e.message}'; + } + } + return 'Произошла ошибка. Попробуйте позже.'; + } +} + +final appConfigProvider = Provider((ref) { + return AppConfig.development; +}); + +final secureStorageProvider = Provider((ref) { + return SecureStorageService(); +}); + +final apiClientProvider = Provider((ref) { + final config = ref.read(appConfigProvider); + final storage = ref.read(secureStorageProvider); + return ApiClient( + baseUrl: config.apiBaseUrl, + storage: storage, + ); +}); + +final authServiceProvider = Provider((ref) { + return AuthService( + firebaseAuth: fb.FirebaseAuth.instance, + apiClient: ref.read(apiClientProvider), + storage: ref.read(secureStorageProvider), + ); +}); + +final authProvider = StateNotifierProvider((ref) { + return AuthNotifier(ref.read(authServiceProvider)); +}); diff --git a/client/lib/core/auth/auth_service.dart b/client/lib/core/auth/auth_service.dart new file mode 100644 index 0000000..085ffd3 --- /dev/null +++ b/client/lib/core/auth/auth_service.dart @@ -0,0 +1,92 @@ +import 'package:firebase_auth/firebase_auth.dart' as fb; +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:google_sign_in/google_sign_in.dart'; + +import '../../shared/models/user.dart'; +import '../api/api_client.dart'; +import 'secure_storage.dart'; + +class AuthService { + final fb.FirebaseAuth _firebaseAuth; + final ApiClient _apiClient; + final SecureStorageService _storage; + + AuthService({ + required fb.FirebaseAuth firebaseAuth, + required ApiClient apiClient, + required SecureStorageService storage, + }) : _firebaseAuth = firebaseAuth, + _apiClient = apiClient, + _storage = storage; + + Future signInWithGoogle() async { + if (kIsWeb) { + // На вебе используем signInWithPopup — открывает Google popup в браузере + final provider = fb.GoogleAuthProvider(); + final userCredential = await _firebaseAuth.signInWithPopup(provider); + return _authenticateWithBackend(userCredential); + } else { + // На мобильных используем google_sign_in + final googleUser = await GoogleSignIn().signIn(); + if (googleUser == null) { + throw Exception('Google sign-in cancelled'); + } + final googleAuth = await googleUser.authentication; + final credential = fb.GoogleAuthProvider.credential( + accessToken: googleAuth.accessToken, + idToken: googleAuth.idToken, + ); + final userCredential = + await _firebaseAuth.signInWithCredential(credential); + return _authenticateWithBackend(userCredential); + } + } + + Future signInWithEmail(String email, String password) async { + final userCredential = await _firebaseAuth.signInWithEmailAndPassword( + email: email, + password: password, + ); + return _authenticateWithBackend(userCredential); + } + + Future registerWithEmail( + String email, String password, String name) async { + final userCredential = await _firebaseAuth.createUserWithEmailAndPassword( + email: email, + password: password, + ); + await userCredential.user!.updateDisplayName(name); + return _authenticateWithBackend(userCredential); + } + + Future _authenticateWithBackend(fb.UserCredential credential) async { + final idToken = await credential.user!.getIdToken(); + + final response = await _apiClient.post('/auth/login', data: { + 'firebase_token': idToken, + }); + + await _storage.saveTokens( + accessToken: response['access_token'], + refreshToken: response['refresh_token'], + ); + + return User.fromJson(response['user']); + } + + Future signOut() async { + try { + await _apiClient.post('/auth/logout'); + } catch (_) { + // Don't block logout on backend failure + } + await _firebaseAuth.signOut(); + await _storage.clearTokens(); + } + + Future isAuthenticated() async { + final token = await _storage.getAccessToken(); + return token != null; + } +} diff --git a/client/lib/core/auth/secure_storage.dart b/client/lib/core/auth/secure_storage.dart new file mode 100644 index 0000000..adbabac --- /dev/null +++ b/client/lib/core/auth/secure_storage.dart @@ -0,0 +1,24 @@ +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +class SecureStorageService { + final FlutterSecureStorage _storage; + + SecureStorageService({FlutterSecureStorage? storage}) + : _storage = storage ?? const FlutterSecureStorage(); + + Future saveTokens({ + required String accessToken, + required String refreshToken, + }) async { + await _storage.write(key: 'access_token', value: accessToken); + await _storage.write(key: 'refresh_token', value: refreshToken); + } + + Future getAccessToken() => _storage.read(key: 'access_token'); + Future getRefreshToken() => _storage.read(key: 'refresh_token'); + + Future clearTokens() async { + await _storage.delete(key: 'access_token'); + await _storage.delete(key: 'refresh_token'); + } +} diff --git a/client/lib/core/config/app_config.dart b/client/lib/core/config/app_config.dart new file mode 100644 index 0000000..d10eebc --- /dev/null +++ b/client/lib/core/config/app_config.dart @@ -0,0 +1,25 @@ +import 'package:flutter/foundation.dart' show kIsWeb, defaultTargetPlatform, TargetPlatform; + +class AppConfig { + final String apiBaseUrl; + + const AppConfig({required this.apiBaseUrl}); + + static AppConfig get development { + if (kIsWeb) { + return const AppConfig(apiBaseUrl: 'http://localhost:9090'); + } + switch (defaultTargetPlatform) { + case TargetPlatform.android: + return const AppConfig(apiBaseUrl: 'http://10.0.2.2:9090'); + case TargetPlatform.iOS: + return const AppConfig(apiBaseUrl: 'http://localhost:9090'); + default: + return const AppConfig(apiBaseUrl: 'http://localhost:9090'); + } + } + + static const production = AppConfig( + apiBaseUrl: 'https://api.food-ai.app', + ); +} diff --git a/client/lib/core/router/app_router.dart b/client/lib/core/router/app_router.dart new file mode 100644 index 0000000..371b28b --- /dev/null +++ b/client/lib/core/router/app_router.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../auth/auth_provider.dart'; +import '../../features/auth/login_screen.dart'; +import '../../features/auth/register_screen.dart'; +import '../../features/home/home_screen.dart'; +import '../../features/products/products_screen.dart'; +import '../../features/menu/menu_screen.dart'; +import '../../features/recipes/recipes_screen.dart'; +import '../../features/profile/profile_screen.dart'; + +final routerProvider = Provider((ref) { + final authState = ref.watch(authProvider); + + return GoRouter( + initialLocation: '/home', + redirect: (context, state) { + final isLoggedIn = authState.status == AuthStatus.authenticated; + final isAuthRoute = state.matchedLocation.startsWith('/auth'); + + if (authState.status == AuthStatus.unknown) return null; + if (!isLoggedIn && !isAuthRoute) return '/auth/login'; + if (isLoggedIn && isAuthRoute) return '/home'; + return null; + }, + routes: [ + GoRoute( + path: '/auth/login', + builder: (_, __) => const LoginScreen(), + ), + GoRoute( + path: '/auth/register', + builder: (_, __) => const RegisterScreen(), + ), + ShellRoute( + builder: (context, state, child) => MainShell(child: child), + routes: [ + GoRoute(path: '/home', builder: (_, __) => const HomeScreen()), + GoRoute( + path: '/products', + builder: (_, __) => const ProductsScreen()), + GoRoute(path: '/menu', builder: (_, __) => const MenuScreen()), + GoRoute( + path: '/recipes', builder: (_, __) => const RecipesScreen()), + GoRoute( + path: '/profile', builder: (_, __) => const ProfileScreen()), + ], + ), + ], + ); +}); + +class MainShell extends StatelessWidget { + final Widget child; + + const MainShell({super.key, required this.child}); + + static const _tabs = [ + '/home', + '/products', + '/menu', + '/recipes', + '/profile', + ]; + + @override + Widget build(BuildContext context) { + final location = GoRouterState.of(context).matchedLocation; + final currentIndex = _tabs.indexOf(location).clamp(0, _tabs.length - 1); + + return Scaffold( + body: child, + bottomNavigationBar: BottomNavigationBar( + currentIndex: currentIndex, + onTap: (index) => context.go(_tabs[index]), + items: const [ + BottomNavigationBarItem( + icon: Icon(Icons.home), label: 'Главная'), + BottomNavigationBarItem( + icon: Icon(Icons.kitchen), label: 'Продукты'), + BottomNavigationBarItem( + icon: Icon(Icons.calendar_month), label: 'Меню'), + BottomNavigationBarItem( + icon: Icon(Icons.menu_book), label: 'Рецепты'), + BottomNavigationBarItem( + icon: Icon(Icons.person), label: 'Профиль'), + ], + ), + ); + } +} diff --git a/client/lib/core/theme/app_colors.dart b/client/lib/core/theme/app_colors.dart new file mode 100644 index 0000000..c13b0ce --- /dev/null +++ b/client/lib/core/theme/app_colors.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; + +abstract class AppColors { + // Primary + static const primary = Color(0xFF4CAF50); + static const primaryLight = Color(0xFF81C784); + static const primaryDark = Color(0xFF388E3C); + + // Accent + static const accent = Color(0xFFFF9800); + + // Background + static const background = Color(0xFFF5F5F5); + static const surface = Color(0xFFFFFFFF); + + // Text + static const textPrimary = Color(0xFF212121); + static const textSecondary = Color(0xFF757575); + + // Status + static const error = Color(0xFFE53935); + static const warning = Color(0xFFFFA726); + static const success = Color(0xFF66BB6A); + + // Shelf life indicators + static const freshGreen = Color(0xFF4CAF50); + static const warningYellow = Color(0xFFFFC107); + static const expiredRed = Color(0xFFE53935); +} diff --git a/client/lib/core/theme/app_theme.dart b/client/lib/core/theme/app_theme.dart new file mode 100644 index 0000000..2d37eaf --- /dev/null +++ b/client/lib/core/theme/app_theme.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; +import 'app_colors.dart'; + +ThemeData appTheme() { + return ThemeData( + useMaterial3: true, + colorSchemeSeed: AppColors.primary, + scaffoldBackgroundColor: AppColors.background, + appBarTheme: const AppBarTheme( + centerTitle: true, + elevation: 0, + backgroundColor: AppColors.surface, + foregroundColor: AppColors.textPrimary, + ), + bottomNavigationBarTheme: const BottomNavigationBarThemeData( + selectedItemColor: AppColors.primary, + unselectedItemColor: AppColors.textSecondary, + type: BottomNavigationBarType.fixed, + ), + inputDecorationTheme: InputDecorationTheme( + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + minimumSize: const Size(double.infinity, 48), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + ), + ); +} diff --git a/client/lib/features/auth/login_screen.dart b/client/lib/features/auth/login_screen.dart new file mode 100644 index 0000000..b914970 --- /dev/null +++ b/client/lib/features/auth/login_screen.dart @@ -0,0 +1,190 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../core/auth/auth_provider.dart'; +import '../../core/theme/app_colors.dart'; + +class LoginScreen extends ConsumerStatefulWidget { + const LoginScreen({super.key}); + + @override + ConsumerState createState() => _LoginScreenState(); +} + +class _LoginScreenState extends ConsumerState { + final _formKey = GlobalKey(); + final _emailController = TextEditingController(); + final _passwordController = TextEditingController(); + + @override + void dispose() { + _emailController.dispose(); + _passwordController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final authState = ref.watch(authProvider); + + ref.listen(authProvider, (previous, next) { + if (next.status == AuthStatus.authenticated) { + context.go('/home'); + } + if (next.error != null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(next.error!), + backgroundColor: AppColors.error, + ), + ); + } + }); + + return Scaffold( + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 60), + Icon( + Icons.restaurant_menu, + size: 80, + color: AppColors.primary, + ), + const SizedBox(height: 16), + Text( + 'FoodAI', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.headlineLarge?.copyWith( + fontWeight: FontWeight.bold, + color: AppColors.primary, + ), + ), + const SizedBox(height: 8), + Text( + 'Управляйте питанием\nс помощью AI', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 48), + TextFormField( + controller: _emailController, + keyboardType: TextInputType.emailAddress, + decoration: const InputDecoration( + labelText: 'Email', + prefixIcon: Icon(Icons.email_outlined), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Введите email'; + } + if (!RegExp(r'^[^@]+@[^@]+\.[^@]+').hasMatch(value)) { + return 'Некорректный email'; + } + return null; + }, + ), + const SizedBox(height: 16), + TextFormField( + controller: _passwordController, + obscureText: true, + decoration: const InputDecoration( + labelText: 'Пароль', + prefixIcon: Icon(Icons.lock_outlined), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Введите пароль'; + } + if (value.length < 6) { + return 'Минимум 6 символов'; + } + return null; + }, + ), + const SizedBox(height: 24), + ElevatedButton( + onPressed: authState.isLoading ? null : _signInWithEmail, + child: authState.isLoading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Войти'), + ), + const SizedBox(height: 24), + Row( + children: [ + const Expanded(child: Divider()), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + 'или', + style: TextStyle(color: AppColors.textSecondary), + ), + ), + const Expanded(child: Divider()), + ], + ), + const SizedBox(height: 24), + OutlinedButton.icon( + onPressed: authState.isLoading ? null : _signInWithGoogle, + icon: const Text('G', + style: TextStyle( + fontSize: 18, fontWeight: FontWeight.bold)), + label: const Text('Войти через Google'), + style: OutlinedButton.styleFrom( + minimumSize: const Size(double.infinity, 48), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12)), + ), + ), + if (defaultTargetPlatform == TargetPlatform.iOS) ...[ + const SizedBox(height: 12), + OutlinedButton.icon( + onPressed: authState.isLoading ? null : () {}, + icon: const Icon(Icons.apple, size: 24), + label: const Text('Войти через Apple'), + style: OutlinedButton.styleFrom( + minimumSize: const Size(double.infinity, 48), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12)), + ), + ), + ], + const SizedBox(height: 24), + TextButton( + onPressed: () => context.go('/auth/register'), + child: const Text('Нет аккаунта? Регистрация'), + ), + ], + ), + ), + ), + ), + ); + } + + void _signInWithEmail() { + if (_formKey.currentState!.validate()) { + ref.read(authProvider.notifier).signInWithEmail( + _emailController.text.trim(), + _passwordController.text, + ); + } + } + + void _signInWithGoogle() { + ref.read(authProvider.notifier).signInWithGoogle(); + } +} diff --git a/client/lib/features/auth/register_screen.dart b/client/lib/features/auth/register_screen.dart new file mode 100644 index 0000000..d0b8803 --- /dev/null +++ b/client/lib/features/auth/register_screen.dart @@ -0,0 +1,158 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../core/auth/auth_provider.dart'; +import '../../core/theme/app_colors.dart'; + +class RegisterScreen extends ConsumerStatefulWidget { + const RegisterScreen({super.key}); + + @override + ConsumerState createState() => _RegisterScreenState(); +} + +class _RegisterScreenState extends ConsumerState { + final _formKey = GlobalKey(); + final _nameController = TextEditingController(); + final _emailController = TextEditingController(); + final _passwordController = TextEditingController(); + final _confirmPasswordController = TextEditingController(); + + @override + void dispose() { + _nameController.dispose(); + _emailController.dispose(); + _passwordController.dispose(); + _confirmPasswordController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final authState = ref.watch(authProvider); + + ref.listen(authProvider, (previous, next) { + if (next.status == AuthStatus.authenticated) { + context.go('/home'); + } + if (next.error != null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(next.error!), + backgroundColor: AppColors.error, + ), + ); + } + }); + + return Scaffold( + appBar: AppBar(title: const Text('Регистрация')), + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 24), + TextFormField( + controller: _nameController, + textCapitalization: TextCapitalization.words, + decoration: const InputDecoration( + labelText: 'Имя', + prefixIcon: Icon(Icons.person_outlined), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Введите имя'; + } + return null; + }, + ), + const SizedBox(height: 16), + TextFormField( + controller: _emailController, + keyboardType: TextInputType.emailAddress, + decoration: const InputDecoration( + labelText: 'Email', + prefixIcon: Icon(Icons.email_outlined), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Введите email'; + } + if (!RegExp(r'^[^@]+@[^@]+\.[^@]+').hasMatch(value)) { + return 'Некорректный email'; + } + return null; + }, + ), + const SizedBox(height: 16), + TextFormField( + controller: _passwordController, + obscureText: true, + decoration: const InputDecoration( + labelText: 'Пароль', + prefixIcon: Icon(Icons.lock_outlined), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Введите пароль'; + } + if (value.length < 6) { + return 'Минимум 6 символов'; + } + return null; + }, + ), + const SizedBox(height: 16), + TextFormField( + controller: _confirmPasswordController, + obscureText: true, + decoration: const InputDecoration( + labelText: 'Подтверждение пароля', + prefixIcon: Icon(Icons.lock_outlined), + ), + validator: (value) { + if (value != _passwordController.text) { + return 'Пароли не совпадают'; + } + return null; + }, + ), + const SizedBox(height: 32), + ElevatedButton( + onPressed: authState.isLoading ? null : _register, + child: authState.isLoading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Зарегистрироваться'), + ), + const SizedBox(height: 16), + TextButton( + onPressed: () => context.go('/auth/login'), + child: const Text('Уже есть аккаунт? Войти'), + ), + ], + ), + ), + ), + ), + ); + } + + void _register() { + if (_formKey.currentState!.validate()) { + ref.read(authProvider.notifier).register( + _emailController.text.trim(), + _passwordController.text, + _nameController.text.trim(), + ); + } + } +} diff --git a/client/lib/features/home/home_screen.dart b/client/lib/features/home/home_screen.dart new file mode 100644 index 0000000..14f9fb6 --- /dev/null +++ b/client/lib/features/home/home_screen.dart @@ -0,0 +1,13 @@ +import 'package:flutter/material.dart'; + +class HomeScreen extends StatelessWidget { + const HomeScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Главная')), + body: const Center(child: Text('Раздел в разработке')), + ); + } +} diff --git a/client/lib/features/menu/menu_screen.dart b/client/lib/features/menu/menu_screen.dart new file mode 100644 index 0000000..ee1859b --- /dev/null +++ b/client/lib/features/menu/menu_screen.dart @@ -0,0 +1,13 @@ +import 'package:flutter/material.dart'; + +class MenuScreen extends StatelessWidget { + const MenuScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Меню')), + body: const Center(child: Text('Раздел в разработке')), + ); + } +} diff --git a/client/lib/features/products/products_screen.dart b/client/lib/features/products/products_screen.dart new file mode 100644 index 0000000..8a70d89 --- /dev/null +++ b/client/lib/features/products/products_screen.dart @@ -0,0 +1,13 @@ +import 'package:flutter/material.dart'; + +class ProductsScreen extends StatelessWidget { + const ProductsScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Продукты')), + body: const Center(child: Text('Раздел в разработке')), + ); + } +} diff --git a/client/lib/features/profile/profile_screen.dart b/client/lib/features/profile/profile_screen.dart new file mode 100644 index 0000000..b9570b6 --- /dev/null +++ b/client/lib/features/profile/profile_screen.dart @@ -0,0 +1,13 @@ +import 'package:flutter/material.dart'; + +class ProfileScreen extends StatelessWidget { + const ProfileScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Профиль')), + body: const Center(child: Text('Раздел в разработке')), + ); + } +} diff --git a/client/lib/features/recipes/recipes_screen.dart b/client/lib/features/recipes/recipes_screen.dart new file mode 100644 index 0000000..35a54a5 --- /dev/null +++ b/client/lib/features/recipes/recipes_screen.dart @@ -0,0 +1,13 @@ +import 'package:flutter/material.dart'; + +class RecipesScreen extends StatelessWidget { + const RecipesScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Рецепты')), + body: const Center(child: Text('Раздел в разработке')), + ); + } +} diff --git a/client/lib/firebase_options.dart b/client/lib/firebase_options.dart new file mode 100644 index 0000000..4be38b8 --- /dev/null +++ b/client/lib/firebase_options.dart @@ -0,0 +1,51 @@ +import 'package:firebase_core/firebase_core.dart' show FirebaseOptions; +import 'package:flutter/foundation.dart' + show defaultTargetPlatform, kIsWeb, TargetPlatform; + +class DefaultFirebaseOptions { + static FirebaseOptions get currentPlatform { + if (kIsWeb) { + return web; + } + switch (defaultTargetPlatform) { + case TargetPlatform.android: + return android; + case TargetPlatform.iOS: + return ios; + default: + throw UnsupportedError( + 'DefaultFirebaseOptions are not supported for this platform.', + ); + } + } + + // Web config — from Firebase Console → Project Settings → Your apps → Web + static const FirebaseOptions web = FirebaseOptions( + apiKey: 'AIzaSyA9m_zlnQP3T9x0_AZGi5yYH-NQ9C1uPqc', + authDomain: 'food-ai-efd52.firebaseapp.com', + projectId: 'food-ai-efd52', + storageBucket: 'food-ai-efd52.firebasestorage.app', + messagingSenderId: '844967109320', + appId: '1:844967109320:web:3a1c6307a6d14810fdbbfd', + measurementId: 'G-PNKBVM3PDN', + ); + + // TODO: заполнить после добавления Android-приложения в Firebase Console + static const FirebaseOptions android = FirebaseOptions( + apiKey: 'YOUR_ANDROID_API_KEY', + appId: 'YOUR_ANDROID_APP_ID', + messagingSenderId: '844967109320', + projectId: 'food-ai-efd52', + storageBucket: 'food-ai-efd52.firebasestorage.app', + ); + + // TODO: заполнить после добавления iOS-приложения в Firebase Console + static const FirebaseOptions ios = FirebaseOptions( + apiKey: 'YOUR_IOS_API_KEY', + appId: 'YOUR_IOS_APP_ID', + messagingSenderId: '844967109320', + projectId: 'food-ai-efd52', + storageBucket: 'food-ai-efd52.firebasestorage.app', + iosBundleId: 'com.foodai.foodAi', + ); +} diff --git a/client/lib/main.dart b/client/lib/main.dart new file mode 100644 index 0000000..14f6c36 --- /dev/null +++ b/client/lib/main.dart @@ -0,0 +1,19 @@ +import 'package:firebase_core/firebase_core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'app.dart'; +import 'firebase_options.dart'; + +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + await Firebase.initializeApp( + options: DefaultFirebaseOptions.currentPlatform, + ); + + runApp( + const ProviderScope( + child: App(), + ), + ); +} diff --git a/client/lib/shared/models/user.dart b/client/lib/shared/models/user.dart new file mode 100644 index 0000000..6e260f1 --- /dev/null +++ b/client/lib/shared/models/user.dart @@ -0,0 +1,47 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'user.g.dart'; + +@JsonSerializable() +class User { + final String id; + final String email; + final String name; + @JsonKey(name: 'avatar_url') + final String? avatarUrl; + @JsonKey(name: 'height_cm') + final int? heightCm; + @JsonKey(name: 'weight_kg') + final double? weightKg; + final int? age; + final String? gender; + final String? activity; + final String? goal; + @JsonKey(name: 'daily_calories') + final int? dailyCalories; + final String plan; + @JsonKey(defaultValue: {}) + final Map preferences; + + const User({ + required this.id, + required this.email, + required this.name, + this.avatarUrl, + this.heightCm, + this.weightKg, + this.age, + this.gender, + this.activity, + this.goal, + this.dailyCalories, + required this.plan, + this.preferences = const {}, + }); + + factory User.fromJson(Map json) => _$UserFromJson(json); + Map toJson() => _$UserToJson(this); + + bool get hasCompletedOnboarding => + heightCm != null && weightKg != null && age != null && gender != null; +} diff --git a/client/lib/shared/models/user.g.dart b/client/lib/shared/models/user.g.dart new file mode 100644 index 0000000..d3309d7 --- /dev/null +++ b/client/lib/shared/models/user.g.dart @@ -0,0 +1,39 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'user.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +User _$UserFromJson(Map json) => User( + id: json['id'] as String, + email: json['email'] as String, + name: json['name'] as String, + avatarUrl: json['avatar_url'] as String?, + heightCm: (json['height_cm'] as num?)?.toInt(), + weightKg: (json['weight_kg'] as num?)?.toDouble(), + age: (json['age'] as num?)?.toInt(), + gender: json['gender'] as String?, + activity: json['activity'] as String?, + goal: json['goal'] as String?, + dailyCalories: (json['daily_calories'] as num?)?.toInt(), + plan: json['plan'] as String, + preferences: json['preferences'] as Map? ?? {}, +); + +Map _$UserToJson(User instance) => { + 'id': instance.id, + 'email': instance.email, + 'name': instance.name, + 'avatar_url': instance.avatarUrl, + 'height_cm': instance.heightCm, + 'weight_kg': instance.weightKg, + 'age': instance.age, + 'gender': instance.gender, + 'activity': instance.activity, + 'goal': instance.goal, + 'daily_calories': instance.dailyCalories, + 'plan': instance.plan, + 'preferences': instance.preferences, +}; diff --git a/client/pubspec.lock b/client/pubspec.lock new file mode 100644 index 0000000..6dd3e6d --- /dev/null +++ b/client/pubspec.lock @@ -0,0 +1,986 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: "8d7ff3948166b8ec5da0fbb5962000926b8e02f2ed9b3e51d1738905fbd4c98d" + url: "https://pub.dev" + source: hosted + version: "93.0.0" + _flutterfire_internals: + dependency: transitive + description: + name: _flutterfire_internals + sha256: ff0a84a2734d9e1089f8aedd5c0af0061b82fb94e95260d943404e0ef2134b11 + url: "https://pub.dev" + source: hosted + version: "1.3.59" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: de7148ed2fcec579b19f122c1800933dfa028f6d9fd38a152b04b1516cec120b + url: "https://pub.dev" + source: hosted + version: "10.0.1" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + url: "https://pub.dev" + source: hosted + version: "2.13.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + build: + dependency: transitive + description: + name: build + sha256: "275bf6bb2a00a9852c28d4e0b410da1d833a734d57d39d44f94bfc895a484ec3" + url: "https://pub.dev" + source: hosted + version: "4.0.4" + build_config: + dependency: transitive + description: + name: build_config + sha256: "4f64382b97504dc2fcdf487d5aae33418e08b4703fc21249e4db6d804a4d0187" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + build_daemon: + dependency: transitive + description: + name: build_daemon + sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957 + url: "https://pub.dev" + source: hosted + version: "4.1.1" + build_runner: + dependency: "direct dev" + description: + name: build_runner + sha256: "39ad4ca8a2876779737c60e4228b4bcd35d4352ef7e14e47514093edc012c734" + url: "https://pub.dev" + source: hosted + version: "2.11.1" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" + url: "https://pub.dev" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + sha256: "7931c90b84bc573fef103548e354258ae4c9d28d140e41961df6843c5d60d4d8" + url: "https://pub.dev" + source: hosted + version: "8.12.3" + cached_network_image: + dependency: "direct main" + description: + name: cached_network_image + sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916" + url: "https://pub.dev" + source: hosted + version: "3.4.1" + cached_network_image_platform_interface: + dependency: transitive + description: + name: cached_network_image_platform_interface + sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829" + url: "https://pub.dev" + source: hosted + version: "4.1.1" + cached_network_image_web: + dependency: transitive + description: + name: cached_network_image_web + sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062" + url: "https://pub.dev" + source: hosted + version: "1.3.1" + characters: + dependency: transitive + description: + name: characters + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + code_builder: + dependency: transitive + description: + name: code_builder + sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d" + url: "https://pub.dev" + source: hosted + version: "4.11.1" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + url: "https://pub.dev" + source: hosted + version: "1.0.8" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: "15a7db352c8fc6a4d2bc475ba901c25b39fe7157541da4c16eacce6f8be83e49" + url: "https://pub.dev" + source: hosted + version: "3.1.5" + dio: + dependency: "direct main" + description: + name: dio + sha256: b9d46faecab38fc8cc286f80bc4d61a3bb5d4ac49e51ed877b4d6706efe57b25 + url: "https://pub.dev" + source: hosted + version: "5.9.1" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + firebase_auth: + dependency: "direct main" + description: + name: firebase_auth + sha256: "0fed2133bee1369ee1118c1fef27b2ce0d84c54b7819a2b17dada5cfec3b03ff" + url: "https://pub.dev" + source: hosted + version: "5.7.0" + firebase_auth_platform_interface: + dependency: transitive + description: + name: firebase_auth_platform_interface + sha256: "871c9df4ec9a754d1a793f7eb42fa3b94249d464cfb19152ba93e14a5966b386" + url: "https://pub.dev" + source: hosted + version: "7.7.3" + firebase_auth_web: + dependency: transitive + description: + name: firebase_auth_web + sha256: d9ada769c43261fd1b18decf113186e915c921a811bd5014f5ea08f4cf4bc57e + url: "https://pub.dev" + source: hosted + version: "5.15.3" + firebase_core: + dependency: "direct main" + description: + name: firebase_core + sha256: "7be63a3f841fc9663342f7f3a011a42aef6a61066943c90b1c434d79d5c995c5" + url: "https://pub.dev" + source: hosted + version: "3.15.2" + firebase_core_platform_interface: + dependency: transitive + description: + name: firebase_core_platform_interface + sha256: cccb4f572325dc14904c02fcc7db6323ad62ba02536833dddb5c02cac7341c64 + url: "https://pub.dev" + source: hosted + version: "6.0.2" + firebase_core_web: + dependency: transitive + description: + name: firebase_core_web + sha256: "0ed0dc292e8f9ac50992e2394e9d336a0275b6ae400d64163fdf0a8a8b556c37" + url: "https://pub.dev" + source: hosted + version: "2.24.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_cache_manager: + dependency: transitive + description: + name: flutter_cache_manager + sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386" + url: "https://pub.dev" + source: hosted + version: "3.4.1" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1" + url: "https://pub.dev" + source: hosted + version: "5.0.0" + flutter_riverpod: + dependency: "direct main" + description: + name: flutter_riverpod + sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1" + url: "https://pub.dev" + source: hosted + version: "2.6.1" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea" + url: "https://pub.dev" + source: hosted + version: "9.2.4" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688 + url: "https://pub.dev" + source: hosted + version: "1.2.3" + flutter_secure_storage_macos: + dependency: transitive + description: + name: flutter_secure_storage_macos + sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247" + url: "https://pub.dev" + source: hosted + version: "3.1.3" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + go_router: + dependency: "direct main" + description: + name: go_router + sha256: f02fd7d2a4dc512fec615529824fdd217fecb3a3d3de68360293a551f21634b3 + url: "https://pub.dev" + source: hosted + version: "14.8.1" + google_identity_services_web: + dependency: transitive + description: + name: google_identity_services_web + sha256: "5d187c46dc59e02646e10fe82665fc3884a9b71bc1c90c2b8b749316d33ee454" + url: "https://pub.dev" + source: hosted + version: "0.3.3+1" + google_sign_in: + dependency: "direct main" + description: + name: google_sign_in + sha256: d0a2c3bcb06e607bb11e4daca48bd4b6120f0bbc4015ccebbe757d24ea60ed2a + url: "https://pub.dev" + source: hosted + version: "6.3.0" + google_sign_in_android: + dependency: transitive + description: + name: google_sign_in_android + sha256: d5e23c56a4b84b6427552f1cf3f98f716db3b1d1a647f16b96dbb5b93afa2805 + url: "https://pub.dev" + source: hosted + version: "6.2.1" + google_sign_in_ios: + dependency: transitive + description: + name: google_sign_in_ios + sha256: "102005f498ce18442e7158f6791033bbc15ad2dcc0afa4cf4752e2722a516c96" + url: "https://pub.dev" + source: hosted + version: "5.9.0" + google_sign_in_platform_interface: + dependency: transitive + description: + name: google_sign_in_platform_interface + sha256: "5f6f79cf139c197261adb6ac024577518ae48fdff8e53205c5373b5f6430a8aa" + url: "https://pub.dev" + source: hosted + version: "2.5.0" + google_sign_in_web: + dependency: transitive + description: + name: google_sign_in_web + sha256: "460547beb4962b7623ac0fb8122d6b8268c951cf0b646dd150d60498430e4ded" + url: "https://pub.dev" + source: hosted + version: "0.12.4+4" + graphs: + dependency: transitive + description: + name: graphs + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + js: + dependency: transitive + description: + name: js + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + url: "https://pub.dev" + source: hosted + version: "0.6.7" + json_annotation: + dependency: "direct main" + description: + name: json_annotation + sha256: "805fa86df56383000f640384b282ce0cb8431f1a7a2396de92fb66186d8c57df" + url: "https://pub.dev" + source: hosted + version: "4.10.0" + json_schema: + dependency: transitive + description: + name: json_schema + sha256: f37d9c3fdfe8c9aae55fdfd5af815d24ce63c3a0f6a2c1f0982c30f43643fa1a + url: "https://pub.dev" + source: hosted + version: "5.2.2" + json_serializable: + dependency: "direct dev" + description: + name: json_serializable + sha256: "93fba3ad139dab2b1ce59ecc6fdce6da46a42cdb6c4399ecda30f1e7e725760d" + url: "https://pub.dev" + source: hosted + version: "6.12.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 + url: "https://pub.dev" + source: hosted + version: "5.1.1" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + url: "https://pub.dev" + source: hosted + version: "0.12.17" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + url: "https://pub.dev" + source: hosted + version: "0.11.1" + meta: + dependency: transitive + description: + name: meta + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + url: "https://pub.dev" + source: hosted + version: "1.16.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + mockito: + dependency: "direct dev" + description: + name: mockito + sha256: a45d1aa065b796922db7b9e7e7e45f921aed17adf3a8318a1f47097e7e695566 + url: "https://pub.dev" + source: hosted + version: "5.6.3" + octo_image: + dependency: transitive + description: + name: octo_image + sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e + url: "https://pub.dev" + source: hosted + version: "2.2.22" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "6d13aece7b3f5c5a9731eaf553ff9dcbc2eff41087fd2df587fd0fed9a3eb0c4" + url: "https://pub.dev" + source: hosted + version: "2.5.1" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.dev" + source: hosted + version: "1.5.2" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + quiver: + dependency: transitive + description: + name: quiver + sha256: ea0b925899e64ecdfbf9c7becb60d5b50e706ade44a85b2363be2a22d88117d2 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + rfc_6901: + dependency: transitive + description: + name: rfc_6901 + sha256: "6a43b1858dca2febaf93e15639aa6b0c49ccdfd7647775f15a499f872b018154" + url: "https://pub.dev" + source: hosted + version: "0.2.1" + riverpod: + dependency: transitive + description: + name: riverpod + sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959" + url: "https://pub.dev" + source: hosted + version: "2.6.1" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + url: "https://pub.dev" + source: hosted + version: "0.28.0" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: "1d562a3c1f713904ebbed50d2760217fd8a51ca170ac4b05b0db490699dbac17" + url: "https://pub.dev" + source: hosted + version: "4.2.0" + source_helper: + dependency: transitive + description: + name: source_helper + sha256: "4a85e90b50694e652075cbe4575665539d253e6ec10e46e76b45368ab5e3caae" + url: "https://pub.dev" + source: hosted + version: "1.3.10" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + sqflite: + dependency: transitive + description: + name: sqflite + sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03 + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sqflite_android: + dependency: transitive + description: + name: sqflite_android + sha256: ecd684501ebc2ae9a83536e8b15731642b9570dc8623e0073d227d0ee2bfea88 + url: "https://pub.dev" + source: hosted + version: "2.4.2+2" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: "6ef422a4525ecc601db6c0a2233ff448c731307906e92cabc9ba292afaae16a6" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + sqflite_darwin: + dependency: transitive + description: + name: sqflite_darwin + sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sqflite_platform_interface: + dependency: transitive + description: + name: sqflite_platform_interface + sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920" + url: "https://pub.dev" + source: hosted + version: "2.4.0" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + state_notifier: + dependency: transitive + description: + name: state_notifier + sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb + url: "https://pub.dev" + source: hosted + version: "1.0.0" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://pub.dev" + source: hosted + version: "2.1.1" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0 + url: "https://pub.dev" + source: hosted + version: "3.4.0" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + url: "https://pub.dev" + source: hosted + version: "0.7.6" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + uri: + dependency: transitive + description: + name: uri + sha256: "889eea21e953187c6099802b7b4cf5219ba8f3518f604a1033064d45b1b8268a" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + uuid: + dependency: transitive + description: + name: uuid + sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8 + url: "https://pub.dev" + source: hosted + version: "4.5.2" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" + url: "https://pub.dev" + source: hosted + version: "15.0.2" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.9.2 <4.0.0" + flutter: ">=3.35.0" diff --git a/client/pubspec.yaml b/client/pubspec.yaml new file mode 100644 index 0000000..d36078c --- /dev/null +++ b/client/pubspec.yaml @@ -0,0 +1,48 @@ +name: food_ai +description: "FoodAI - AI-powered food management" +publish_to: 'none' +version: 1.0.0+1 + +environment: + sdk: ^3.9.2 + +dependencies: + flutter: + sdk: flutter + + cupertino_icons: ^1.0.8 + + # Navigation + go_router: ^14.0.0 + + # State management + flutter_riverpod: ^2.5.0 + + # Network + dio: ^5.4.0 + + # Firebase + firebase_core: ^3.0.0 + firebase_auth: ^5.0.0 + google_sign_in: ^6.2.0 + + # Storage + flutter_secure_storage: ^9.2.0 + + # Serialization + json_annotation: ^4.9.0 + + # UI + cached_network_image: ^3.3.0 + +dev_dependencies: + flutter_test: + sdk: flutter + + flutter_lints: ^5.0.0 + json_serializable: ^6.8.0 + build_runner: ^2.4.0 + mockito: ^5.4.0 + +flutter: + uses-material-design: true diff --git a/client/test/features/auth/login_screen_test.dart b/client/test/features/auth/login_screen_test.dart new file mode 100644 index 0000000..c71fbdd --- /dev/null +++ b/client/test/features/auth/login_screen_test.dart @@ -0,0 +1,115 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; + +import 'package:food_ai/core/auth/auth_provider.dart'; +import 'package:food_ai/features/auth/login_screen.dart'; + +class _TestAuthNotifier extends AuthNotifier { + _TestAuthNotifier(super.initialState) : super.test(); + + @override + Future signInWithEmail(String email, String password) async {} + + @override + Future signInWithGoogle() async {} + + @override + Future register(String email, String password, String name) async {} + + @override + Future signOut() async {} +} + +Widget _buildTestWidget({AuthState? initialState}) { + final authState = initialState ?? + const AuthState(status: AuthStatus.unauthenticated); + + final router = GoRouter( + initialLocation: '/auth/login', + routes: [ + GoRoute( + path: '/auth/login', + builder: (_, __) => const LoginScreen(), + ), + GoRoute( + path: '/auth/register', + builder: (_, __) => + const Scaffold(body: Text('Register Screen')), + ), + GoRoute( + path: '/home', + builder: (_, __) => + const Scaffold(body: Text('Home Screen')), + ), + ], + ); + + return ProviderScope( + overrides: [ + authProvider.overrideWith( + (ref) => _TestAuthNotifier(authState), + ), + ], + child: MaterialApp.router(routerConfig: router), + ); +} + +void main() { + testWidgets('renders email and password fields and buttons', + (tester) async { + await tester.pumpWidget(_buildTestWidget()); + await tester.pumpAndSettle(); + + expect(find.text('Email'), findsOneWidget); + expect(find.text('Пароль'), findsOneWidget); + expect(find.text('Войти'), findsOneWidget); + expect(find.text('Войти через Google'), findsOneWidget); + expect(find.text('Нет аккаунта? Регистрация'), findsOneWidget); + }); + + testWidgets('validates empty email', (tester) async { + await tester.pumpWidget(_buildTestWidget()); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Войти')); + await tester.pumpAndSettle(); + + expect(find.text('Введите email'), findsOneWidget); + }); + + testWidgets('validates invalid email format', (tester) async { + await tester.pumpWidget(_buildTestWidget()); + await tester.pumpAndSettle(); + + await tester.enterText( + find.widgetWithText(TextFormField, 'Email'), 'abc'); + await tester.tap(find.text('Войти')); + await tester.pumpAndSettle(); + + expect(find.text('Некорректный email'), findsOneWidget); + }); + + testWidgets('validates short password', (tester) async { + await tester.pumpWidget(_buildTestWidget()); + await tester.pumpAndSettle(); + + await tester.enterText( + find.widgetWithText(TextFormField, 'Email'), 'test@test.com'); + await tester.enterText( + find.widgetWithText(TextFormField, 'Пароль'), '123'); + await tester.tap(find.text('Войти')); + await tester.pumpAndSettle(); + + expect(find.text('Минимум 6 символов'), findsOneWidget); + }); + + testWidgets('shows FoodAI branding', (tester) async { + await tester.pumpWidget(_buildTestWidget()); + await tester.pumpAndSettle(); + + expect(find.text('FoodAI'), findsOneWidget); + expect(find.text('Управляйте питанием\nс помощью AI'), findsOneWidget); + }); +} diff --git a/client/test/features/auth/register_screen_test.dart b/client/test/features/auth/register_screen_test.dart new file mode 100644 index 0000000..1c99597 --- /dev/null +++ b/client/test/features/auth/register_screen_test.dart @@ -0,0 +1,111 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; + +import 'package:food_ai/core/auth/auth_provider.dart'; +import 'package:food_ai/features/auth/register_screen.dart'; + +class _TestAuthNotifier extends AuthNotifier { + _TestAuthNotifier() + : super.test(const AuthState(status: AuthStatus.unauthenticated)); + + @override + Future signInWithEmail(String email, String password) async {} + + @override + Future signInWithGoogle() async {} + + @override + Future register(String email, String password, String name) async {} + + @override + Future signOut() async {} +} + +Widget _buildTestWidget() { + final router = GoRouter( + initialLocation: '/auth/register', + routes: [ + GoRoute( + path: '/auth/register', + builder: (_, __) => const RegisterScreen(), + ), + GoRoute( + path: '/auth/login', + builder: (_, __) => + const Scaffold(body: Text('Login Screen')), + ), + GoRoute( + path: '/home', + builder: (_, __) => + const Scaffold(body: Text('Home Screen')), + ), + ], + ); + + return ProviderScope( + overrides: [ + authProvider.overrideWith( + (ref) => _TestAuthNotifier(), + ), + ], + child: MaterialApp.router(routerConfig: router), + ); +} + +void main() { + testWidgets('renders all form fields', (tester) async { + await tester.pumpWidget(_buildTestWidget()); + await tester.pumpAndSettle(); + + expect(find.text('Имя'), findsOneWidget); + expect(find.text('Email'), findsOneWidget); + expect(find.text('Пароль'), findsOneWidget); + expect(find.text('Подтверждение пароля'), findsOneWidget); + expect(find.text('Зарегистрироваться'), findsOneWidget); + }); + + testWidgets('validates empty name', (tester) async { + await tester.pumpWidget(_buildTestWidget()); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Зарегистрироваться')); + await tester.pumpAndSettle(); + + expect(find.text('Введите имя'), findsOneWidget); + }); + + testWidgets('validates password mismatch', (tester) async { + await tester.pumpWidget(_buildTestWidget()); + await tester.pumpAndSettle(); + + await tester.enterText( + find.widgetWithText(TextFormField, 'Имя'), 'Test'); + await tester.enterText( + find.widgetWithText(TextFormField, 'Email'), 'test@test.com'); + await tester.enterText( + find.widgetWithText(TextFormField, 'Пароль'), 'password123'); + await tester.enterText( + find.widgetWithText(TextFormField, 'Подтверждение пароля'), + 'different'); + await tester.tap(find.text('Зарегистрироваться')); + await tester.pumpAndSettle(); + + expect(find.text('Пароли не совпадают'), findsOneWidget); + }); + + testWidgets('has link to login', (tester) async { + await tester.pumpWidget(_buildTestWidget()); + await tester.pumpAndSettle(); + + expect(find.text('Уже есть аккаунт? Войти'), findsOneWidget); + }); + + testWidgets('shows registration title', (tester) async { + await tester.pumpWidget(_buildTestWidget()); + await tester.pumpAndSettle(); + + expect(find.text('Регистрация'), findsOneWidget); + }); +} diff --git a/client/test/shared/models/user_test.dart b/client/test/shared/models/user_test.dart new file mode 100644 index 0000000..b1b4b25 --- /dev/null +++ b/client/test/shared/models/user_test.dart @@ -0,0 +1,159 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:food_ai/shared/models/user.dart'; + +void main() { + group('User.fromJson', () { + test('deserializes full object', () { + final json = { + 'id': 'user-1', + 'email': 'test@example.com', + 'name': 'Test User', + 'avatar_url': 'https://avatar.url/pic.jpg', + 'height_cm': 180, + 'weight_kg': 75.5, + 'age': 28, + 'gender': 'male', + 'activity': 'moderate', + 'goal': 'maintain', + 'daily_calories': 2500, + 'plan': 'free', + 'preferences': {'cuisines': ['russian']}, + }; + + final user = User.fromJson(json); + + expect(user.id, 'user-1'); + expect(user.email, 'test@example.com'); + expect(user.name, 'Test User'); + expect(user.avatarUrl, 'https://avatar.url/pic.jpg'); + expect(user.heightCm, 180); + expect(user.weightKg, 75.5); + expect(user.age, 28); + expect(user.gender, 'male'); + expect(user.activity, 'moderate'); + expect(user.goal, 'maintain'); + expect(user.dailyCalories, 2500); + expect(user.plan, 'free'); + expect(user.preferences['cuisines'], ['russian']); + }); + + test('deserializes minimal object (required fields only)', () { + final json = { + 'id': 'user-2', + 'email': 'min@example.com', + 'name': 'Min User', + 'plan': 'paid', + }; + + final user = User.fromJson(json); + + expect(user.id, 'user-2'); + expect(user.email, 'min@example.com'); + expect(user.name, 'Min User'); + expect(user.plan, 'paid'); + expect(user.avatarUrl, isNull); + expect(user.heightCm, isNull); + expect(user.weightKg, isNull); + expect(user.age, isNull); + expect(user.gender, isNull); + expect(user.dailyCalories, isNull); + expect(user.preferences, isEmpty); + }); + + test('handles snake_case to camelCase mapping', () { + final json = { + 'id': 'user-3', + 'email': 'test@example.com', + 'name': 'Test', + 'plan': 'free', + 'avatar_url': 'https://pic.url', + 'height_cm': 170, + 'weight_kg': 65.0, + 'daily_calories': 2000, + }; + + final user = User.fromJson(json); + + expect(user.avatarUrl, 'https://pic.url'); + expect(user.heightCm, 170); + expect(user.weightKg, 65.0); + expect(user.dailyCalories, 2000); + }); + + test('roundtrip fromJson -> toJson -> fromJson', () { + final original = User( + id: 'user-4', + email: 'roundtrip@example.com', + name: 'Roundtrip', + avatarUrl: 'https://avatar.url', + heightCm: 175, + weightKg: 70.0, + age: 30, + gender: 'female', + activity: 'high', + goal: 'lose', + dailyCalories: 1800, + plan: 'paid', + preferences: {'cuisines': ['asian']}, + ); + + final json = original.toJson(); + final restored = User.fromJson(json); + + expect(restored.id, original.id); + expect(restored.email, original.email); + expect(restored.name, original.name); + expect(restored.avatarUrl, original.avatarUrl); + expect(restored.heightCm, original.heightCm); + expect(restored.weightKg, original.weightKg); + expect(restored.age, original.age); + expect(restored.gender, original.gender); + expect(restored.plan, original.plan); + expect(restored.dailyCalories, original.dailyCalories); + }); + + test('ignores unknown fields', () { + final json = { + 'id': 'user-5', + 'email': 'test@example.com', + 'name': 'Test', + 'plan': 'free', + 'unknown_field': 42, + 'another_unknown': 'hello', + }; + + final user = User.fromJson(json); + + expect(user.id, 'user-5'); + expect(user.email, 'test@example.com'); + }); + }); + + group('User.hasCompletedOnboarding', () { + test('returns false when body params are missing', () { + const user = User( + id: '1', + email: 'test@test.com', + name: 'Test', + plan: 'free', + ); + + expect(user.hasCompletedOnboarding, isFalse); + }); + + test('returns true when all body params are present', () { + const user = User( + id: '1', + email: 'test@test.com', + name: 'Test', + plan: 'free', + heightCm: 180, + weightKg: 75.0, + age: 28, + gender: 'male', + ); + + expect(user.hasCompletedOnboarding, isTrue); + }); + }); +} diff --git a/client/web/favicon.png b/client/web/favicon.png new file mode 100644 index 0000000..8aaa46a Binary files /dev/null and b/client/web/favicon.png differ diff --git a/client/web/icons/Icon-192.png b/client/web/icons/Icon-192.png new file mode 100644 index 0000000..b749bfe Binary files /dev/null and b/client/web/icons/Icon-192.png differ diff --git a/client/web/icons/Icon-512.png b/client/web/icons/Icon-512.png new file mode 100644 index 0000000..88cfd48 Binary files /dev/null and b/client/web/icons/Icon-512.png differ diff --git a/client/web/icons/Icon-maskable-192.png b/client/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000..eb9b4d7 Binary files /dev/null and b/client/web/icons/Icon-maskable-192.png differ diff --git a/client/web/icons/Icon-maskable-512.png b/client/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000..d69c566 Binary files /dev/null and b/client/web/icons/Icon-maskable-512.png differ diff --git a/client/web/index.html b/client/web/index.html new file mode 100644 index 0000000..b35f0e6 --- /dev/null +++ b/client/web/index.html @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + FoodAI + + + + + + + + diff --git a/client/web/manifest.json b/client/web/manifest.json new file mode 100644 index 0000000..79771cb --- /dev/null +++ b/client/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "food_ai", + "short_name": "food_ai", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/docs/Description.md b/docs/Description.md new file mode 100644 index 0000000..ff76d3a --- /dev/null +++ b/docs/Description.md @@ -0,0 +1,250 @@ +# FoodAI — мобильное приложение для управления питанием + +## Концепция + +FoodAI — мобильное приложение, которое помогает пользователю управлять своим питанием: планировать меню, вести учёт калорий и контролировать запасы продуктов. Ключевая особенность — использование камеры телефона для распознавания продуктов, чеков и готовых блюд с автоматическим подсчётом калорий и подбором рецептов. + +## Целевая аудитория + +- Люди, следящие за питанием и калорийностью рациона +- Те, кто хочет разнообразить своё меню и научиться готовить +- Люди, которые хотят минимизировать пищевые отходы, используя имеющиеся продукты +- Начинающие кулинары, которым важен пошаговый процесс готовки + +## Основные функции + +### 1. Распознавание продуктов через камеру + +- **Сканирование чека.** Пользователь фотографирует чек из магазина. Приложение распознаёт список купленных продуктов и автоматически добавляет их в запасы. +- **Фото продуктов.** Пользователь фотографирует продукты (например, содержимое холодильника). Приложение определяет, что на фото, и добавляет распознанные продукты в запасы. +- **Фото готового блюда.** Пользователь фотографирует готовое блюдо или порцию еды. Приложение определяет блюдо и примерную калорийность. +- **Мультифото.** При фотографировании продуктов можно сделать несколько снимков подряд (например, разные полки холодильника), и результаты объединяются в один общий список. +- **Корректировка результатов.** После любого распознавания пользователь может отредактировать каждый продукт: изменить название, вес, количество, категорию, удалить ошибочно распознанный или добавить пропущенный. + +### 2. Учёт продуктов + +- Список имеющихся продуктов с возможностью ручного редактирования. +- Автоматическое пополнение запасов через сканирование чеков и фото. +- Автоматическое списание продуктов при отметке приготовленных рецептов. +- **Срок годности как период хранения.** Вместо фиксированной даты «до XX.XX» используется модель «срок хранения после покупки» (например, молоко — 5 дней). Дата вычисляется автоматически от даты добавления. Период можно скорректировать вручную для конкретного продукта. Для каждой категории продуктов есть значения по умолчанию, которые пользователь может настроить. +- Уведомления о продуктах с истекающим сроком годности. +- **Частичное использование.** Возможность отметить, что продукт использован частично (открытое молоко, отрезанный кусок сыра), с корректировкой оставшегося количества. +- **Сброс и перезаполнение.** Возможность очистить все продукты и перезаполнить список заново — через фото содержимого холодильника или сканирование чека. Полезно при инвентаризации запасов. +- **Объединение дубликатов.** При добавлении продукта, который уже есть в запасах, приложение предлагает объединить (прибавить количество) или добавить отдельной позицией (если разные сроки годности/партии). +- **Единицы измерения.** Каждый продукт хранится с единицей измерения (г, кг, мл, л, шт, пучок, упаковка). При добавлении система предлагает наиболее подходящую единицу для категории, с возможностью смены. + +### 3. Подбор меню и рекомендации + +На основе имеющихся продуктов приложение предлагает рецепты с учётом фильтров: + +- **По периоду:** на один приём пищи, на день, на неделю. +- **По сложности:** быстрые и простые блюда, средняя сложность, сложные рецепты. +- **По кухне:** азиатская, европейская, средиземноморская, русская и другие. +- **По типу приёма пищи:** завтрак, обед, ужин, перекус. +- **По диетическим предпочтениям:** вегетарианское, безглютеновое, низкокалорийное и т.д. + +Если для рецепта не хватает некоторых продуктов, приложение показывает, чего именно не хватает, и формирует список покупок. + +**Рекомендации.** Приложение проактивно предлагает рецепты и меню: + +- На главном экране — блок «Рекомендуем приготовить» на основе имеющихся продуктов (приоритет: продукты с истекающим сроком). +- После добавления продуктов (чек/фото) — экран-предложение «Составить меню из купленного?» с вариантами кухонь, сложности и периода. +- В каталоге рецептов — персональная лента «Для вас» на основе предпочтений, истории, оценок. +- На экране меню при пустых слотах — подсказка «Подобрать блюдо» с учётом оставшегося бюджета калорий на день. +- **Замена ингредиентов.** Если для рецепта не хватает ингредиента, приложение может предложить замену из имеющихся продуктов (например, пекорино → пармезан). +- **Быстрый повтор.** Часто готовимые блюда отображаются в секции «Готовили недавно» для быстрого доступа. + +### 4. Планирование меню + +- Составление меню на каждый день недели. +- **Автогенерация меню.** Приложение может сгенерировать меню на день или неделю целиком по заданным параметрам (кухня, калорийность, сложность, из имеющихся продуктов). Пользователь может принять, заменить отдельные блюда или сгенерировать заново. +- Редактирование и перестановка блюд между днями. +- Автоматический подсчёт суммарной калорийности за день/неделю. +- Формирование списка покупок на основе запланированного меню и имеющихся запасов. +- История меню — возможность вернуться к удачному плану питания прошлых недель. +- **Шаблоны меню.** Возможность сохранить текущее меню как шаблон (например, «Моя рабочая неделя», «Безглютеновая неделя») и применять его повторно. + +### 5. Учёт калорий + +- Автоматический подсчёт калорий на основе добавленных в дневник приёмов пищи. +- Распознавание калорийности по фото готового блюда или продуктов. +- Дневник питания: запись того, что было съедено за день. +- **Размер порции.** При записи в дневник можно указать, какую долю порции съел пользователь (1, 0.5, 1.5 порции и т.д.). +- Отображение баланса БЖУ (белки, жиры, углеводы). +- Настройка целевой калорийности и отслеживание прогресса. +- Графики и статистика за день, неделю, месяц. +- **Быстрое добавление.** Перекусы, не являющиеся рецептом (банан, чай с сахаром), можно добавить через поиск по базе продуктов без необходимости фотографировать. + +### 6. Рецепты + +- Каталог рецептов с поиском и фильтрацией. +- Карточка рецепта: список ингредиентов, калорийность, время приготовления, сложность, кухня. +- Возможность оставить отзыв и оценку к рецепту. +- Добавление рецепта в избранное. +- Просмотр отзывов других пользователей. +- **Замена ингредиентов.** В карточке рецепта для каждого ингредиента отображаются возможные замены. Если основного ингредиента нет в запасах, но есть подходящая замена — это отражается в наличии. +- **Пользовательские рецепты.** Возможность создать и сохранить свой рецепт, который будет доступен в личном каталоге. + +### 7. Пошаговая готовка с таймерами + +- Режим готовки: рецепт разбит на последовательные шаги с фото и описанием. +- На шагах, требующих ожидания (варить 15 минут, дать настояться 30 минут), отображается кнопка запуска таймера. +- Нажатие на шаг с таймером запускает обратный отсчёт с уведомлением по завершении. +- Возможность запуска нескольких таймеров параллельно (например, пока варится паста, готовится соус). +- Экран не гаснет в режиме готовки. +- **Завершение готовки:** после последнего шага — предложение записать в дневник, оценить рецепт и поделиться фото результата. + +### 8. Онбординг + +Первый запуск приложения включает короткий пошаговый процесс: + +1. Приветствие и краткое описание возможностей (2–3 карточки с иллюстрациями). +2. Указание базовых параметров: пол, возраст, рост, вес, уровень активности. +3. Выбор цели: похудение, поддержание, набор массы. Расчёт рекомендуемой калорийности. +4. Указание ограничений и предпочтений (аллергии, диеты). +5. Предпочтения по кухням (выбрать 2–3 из списка). +6. Предложение добавить первые продукты: сфотографировать холодильник, сканировать чек или пропустить. +7. Переход на главный экран. + +## Экраны приложения + +### Главный экран +- Сводка на сегодня: запланированные приёмы пищи, текущий баланс калорий. +- Быстрые действия: сфотографировать чек, сфотографировать еду, найти рецепт. +- **Рекомендации:** блок «Рекомендуем приготовить» — 2–3 карточки рецептов на основе имеющихся продуктов (приоритет — продукты с истекающим сроком). +- **Быстрый повтор:** секция «Готовили недавно» — последние 3–5 приготовленных рецептов для быстрого повтора. + +### Мои продукты +- Список имеющихся продуктов, сгруппированных по категориям. +- Кнопка добавления: вручную, через фото, через сканирование чека. +- **Кнопка сброса/перезаполнения** в контекстном меню. +- Срок годности отображается как «осталось X дней» (вычисляется от даты добавления + период хранения). + +### Экран корректировки после распознавания +- Общий для всех типов распознавания (чек, фото продуктов, фото блюда). +- Каждый продукт: редактируемое название, вес/количество (инлайн-редактирование), единица измерения, категория, период хранения. +- **Переход к составлению меню** — после подтверждения продуктов. + +### Переходный экран «Составить меню?» +- Появляется после добавления продуктов через чек или фото. +- Предлагает составить меню из добавленных (и имеющихся) продуктов. +- Быстрый выбор: на день / на неделю, кухня, сложность. +- Кнопки «Составить меню» и «Пропустить». + +### Меню +- Календарь с запланированными приёмами пищи. +- Возможность перетаскивания блюд между днями. +- Итоговая калорийность по каждому дню. +- **Кнопка автогенерации** меню на неделю. +- **Подсказки** в пустых слотах с рекомендациями. + +### Каталог рецептов +- Поиск и фильтры (кухня, сложность, время, калорийность, тип приёма пищи). +- Карточки рецептов с фото, названием, калорийностью и рейтингом. +- Кнопка «Подобрать из моих продуктов». +- **Секция «Для вас»** — персональные рекомендации. +- **Секция «Готовили недавно»** — быстрый повтор. + +### Карточка рецепта +- Фото блюда. +- Описание, время приготовления, сложность, калорийность, БЖУ. +- Список ингредиентов с отметкой наличия в запасах и **предложениями замен**. +- Кнопка «Начать готовить» — переход в режим пошаговой готовки. +- Отзывы и оценки. +- Кнопка «Добавить в меню». + +### Режим готовки +- Пошаговое отображение процесса. +- Крупный текст и фото для удобства на кухне. +- Кнопки таймеров на соответствующих шагах. +- Панель активных таймеров внизу экрана. +- **Экран завершения:** предложение записать в дневник, оценить, поделиться. + +### Дневник питания +- Записи о приёмах пищи за день. +- Добавление: из рецепта, из меню, через фото, вручную, **из быстрого поиска по базе продуктов**. +- **Регулировка размера порции** при добавлении. +- Итоги дня: калории, БЖУ, прогресс к цели. + +### Статистика +- Графики калорийности и БЖУ за выбранный период. +- Сравнение с целевыми показателями. +- Тренды и динамика. + +### Список покупок +- Формируется автоматически из запланированного меню с учётом имеющихся запасов. +- Возможность ручного редактирования. +- Отметка купленных позиций. + +### Профиль +- Персональные данные (вес, рост, возраст, уровень активности). +- Цели (похудение, набор массы, поддержание). +- Расчёт рекомендуемой суточной калорийности. +- Диетические предпочтения и ограничения. +- **Настройки сроков хранения** по категориям продуктов. +- **Мои рецепты** — пользовательские рецепты. + +## Пользовательские сценарии + +### Сценарий 1: Первый запуск +1. Пользователь скачивает приложение и проходит онбординг. +2. Указывает параметры, цели, предпочтения по кухням. +3. Фотографирует содержимое холодильника. +4. Корректирует список продуктов (вес, количество, названия). +5. Приложение предлагает составить меню из того, что есть. +6. Пользователь получает первое сгенерированное меню. + +### Сценарий 2: Пришёл из магазина +1. Пользователь фотографирует чек. +2. Приложение распознаёт продукты. Пользователь корректирует (редактирует вес/количество, удаляет лишнее, добавляет пропущенное). +3. Для дубликатов — предложение объединить с имеющимися. +4. Подтверждает → переходный экран: «Составить меню из купленного?» +5. Выбирает: на неделю, европейская кухня, средняя сложность. +6. Получает сгенерированное меню, корректирует, сохраняет. + +### Сценарий 3: Что приготовить из того, что есть? +1. Пользователь открывает подбор рецептов. +2. Выбирает фильтры: «из моих продуктов», «ужин», «до 30 минут», «азиатская кухня». +3. Получает список подходящих рецептов, отсортированных по степени совпадения с имеющимися продуктами. +4. Для рецепта, где не хватает ингредиента, видит предложение замены (пекорино → пармезан). +5. Выбирает рецепт и начинает пошаговую готовку. + +### Сценарий 4: Планирование недели +1. Пользователь открывает экран меню. +2. Нажимает «Автогенерация» → выбирает параметры (кухня, калорийность, из моих продуктов). +3. Приложение генерирует меню на неделю. Пользователь заменяет пару блюд вручную. +4. Формируется список покупок — с учётом имеющихся запасов. + +### Сценарий 5: Учёт калорий на ходу +1. Пользователь обедает в кафе. +2. Фотографирует блюдо. +3. Приложение определяет блюдо и его примерную калорийность. +4. Пользователь корректирует размер порции. +5. Данные записываются в дневник питания. + +### Сценарий 6: Готовка по рецепту +1. Пользователь открывает рецепт и нажимает «Начать готовить». +2. Следует пошаговым инструкциям. +3. На шаге «Варить бульон 40 минут» нажимает на таймер. +4. Переходит к параллельным шагам (нарезка овощей) пока таймер тикает. +5. Получает уведомление о готовности бульона. +6. На последнем шаге — предложение записать блюдо в дневник и оценить рецепт. + +### Сценарий 7: Инвентаризация продуктов +1. Пользователь давно не обновлял список продуктов. +2. Открывает «Мои продукты» → контекстное меню → «Очистить и перезаполнить». +3. Подтверждает сброс. +4. Фотографирует содержимое холодильника (несколько фото). +5. Корректирует распознанный список. +6. Продукты обновлены, сроки пересчитаны от текущей даты. + +### Сценарий 8: Использование рекомендаций +1. Пользователь открывает приложение утром. +2. На главном экране видит: «Рекомендуем приготовить: Стир-фрай с курицей — используйте курицу (срок истекает завтра) и овощи из запасов». +3. Тап по карточке → карточка рецепта → «Начать готовить». + +## Монетизация (варианты) + +- **Freemium.** Базовые функции бесплатно (ручной ввод продуктов, просмотр рецептов, планирование меню). Распознавание через камеру, расширенная аналитика, персональные рекомендации — по подписке. +- **Подписка.** Ежемесячная/годовая подписка на полный функционал. +- **Без рекламы.** Бесплатная версия с рекламой, платная — без. diff --git a/docs/Design.md b/docs/Design.md new file mode 100644 index 0000000..cda9617 --- /dev/null +++ b/docs/Design.md @@ -0,0 +1,1735 @@ +# FoodAI — Дизайн экранов (Wireframe-описания) + +## Навигация + +Приложение использует нижнюю панель навигации (Bottom Tab Bar) с 5 вкладками: + +``` +┌─────────────────────────────────────┐ +│ [Главная] [Продукты] [Меню] [Рецепты] [Профиль] │ +└─────────────────────────────────────┘ +``` + +Иконки вкладок: домик, корзина, календарь, книга, аватар. + +Активная вкладка подсвечивается акцентным цветом. Над иконкой «Продукты» может отображаться badge с количеством продуктов с истекающим сроком. + +--- + +## 0. Онбординг (первый запуск) + +Пошаговый процесс при первом запуске. Знакомит пользователя с приложением и собирает данные для персонализации. + +### Шаг 1 — Приветствие (2–3 карточки, свайп) + +``` +┌─────────────────────────────────────┐ +│ │ +│ [Иллюстрация] │ +│ │ +│ Управляйте питанием │ +│ с помощью камеры │ +│ │ +│ Сфотографируйте чек или │ +│ продукты — мы распознаем │ +│ их и подберём рецепты │ +│ │ +│ ● ○ ○ │ +│ │ +│ ┌───────────────────────────────┐ │ +│ │ Далее │ │ +│ └───────────────────────────────┘ │ +│ │ +│ Пропустить │ +│ │ +└─────────────────────────────────────┘ +``` + +### Шаг 2 — Параметры тела + +``` +┌─────────────────────────────────────┐ +│ Расскажите о себе │ +├─────────────────────────────────────┤ +│ │ +│ Пол │ +│ [Мужской] [Женский] │ +│ │ +│ Возраст │ +│ ┌──────────────────────────┐ │ +│ │ 28 │ │ +│ └──────────────────────────┘ │ +│ │ +│ Рост (см) │ +│ ┌──────────────────────────┐ │ +│ │ 178 │ │ +│ └──────────────────────────┘ │ +│ │ +│ Вес (кг) │ +│ ┌──────────────────────────┐ │ +│ │ 75 │ │ +│ └──────────────────────────┘ │ +│ │ +│ Уровень активности │ +│ [Низкая] [Средняя●] [Высокая] │ +│ │ +│ ┌───────────────────────────────┐ │ +│ │ Далее │ │ +│ └───────────────────────────────┘ │ +│ │ +└─────────────────────────────────────┘ +``` + +### Шаг 3 — Цель и расчёт + +``` +┌─────────────────────────────────────┐ +│ Ваша цель │ +├─────────────────────────────────────┤ +│ │ +│ ┌───────────────────────────────┐ │ +│ │ ○ Похудение │ │ +│ │ Дефицит калорий │ │ +│ ├───────────────────────────────┤ │ +│ │ ● Поддержание веса │ │ +│ │ Сбалансированное питание │ │ +│ ├───────────────────────────────┤ │ +│ │ ○ Набор массы │ │ +│ │ Профицит калорий │ │ +│ └───────────────────────────────┘ │ +│ │ +│ Ваша рекомендуемая норма: │ +│ ┌───────────────────────────────┐ │ +│ │ 2 100 ккал/день │ │ +│ │ Б: 120г · Ж: 70г · У: 260г │ │ +│ └───────────────────────────────┘ │ +│ │ +│ ┌───────────────────────────────┐ │ +│ │ Далее │ │ +│ └───────────────────────────────┘ │ +│ │ +└─────────────────────────────────────┘ +``` + +### Шаг 4 — Ограничения и предпочтения кухонь + +``` +┌─────────────────────────────────────┐ +│ Ограничения в питании │ +├─────────────────────────────────────┤ +│ │ +│ Есть ли аллергии или ограничения? │ +│ (можно выбрать несколько) │ +│ │ +│ [ ] Вегетарианство │ +│ [ ] Без глютена │ +│ [ ] Без лактозы │ +│ [✓] Без орехов │ +│ [ ] Халяль │ +│ [ ] Кошерное │ +│ │ +│ Какая кухня вам нравится? │ +│ (выберите 1–3) │ +│ │ +│ [Русская✓] [Азиатская✓] [Европ.] │ +│ [Средизем.] [Американ.] [Кавказ.] │ +│ │ +│ ┌───────────────────────────────┐ │ +│ │ Далее │ │ +│ └───────────────────────────────┘ │ +│ │ +└─────────────────────────────────────┘ +``` + +### Шаг 5 — Добавить первые продукты + +``` +┌─────────────────────────────────────┐ +│ Добавьте ваши продукты │ +├─────────────────────────────────────┤ +│ │ +│ [Иллюстрация: │ +│ холодильник] │ +│ │ +│ Сфотографируйте содержимое │ +│ холодильника или чек из магазина, │ +│ и мы подберём рецепты │ +│ │ +│ ┌───────────────────────────────┐ │ +│ │ 📷 Сфотографировать продукты │ │ +│ └───────────────────────────────┘ │ +│ ┌───────────────────────────────┐ │ +│ │ 🧾 Сканировать чек │ │ +│ └───────────────────────────────┘ │ +│ │ +│ Пропустить │ +│ │ +└─────────────────────────────────────┘ +``` + +### Элементы и поведение + +- **Навигация:** «Далее» и «Пропустить» на каждом шаге. Индикатор прогресса (точки или прогресс-бар). +- **Шаг 2:** все поля необязательны, но при пропуске рекомендуемая калорийность не рассчитывается (предлагается средняя 2000 ккал). +- **Шаг 3:** норма пересчитывается при смене цели (формула Миффлина-Сан Жеора + коэффициент активности + поправка на цель). +- **Шаг 4:** предпочтения кухонь влияют на будущие рекомендации, ограничения — на фильтрацию рецептов. +- **Шаг 5:** при выборе фото/чека — переход к экрану камеры, затем к экрану корректировки распознанных продуктов, затем — к переходному экрану «Составить меню?». При «Пропустить» — переход на главный экран. +- **Повторный запуск:** онбординг показывается один раз. Все данные можно изменить позже в Профиле. + +--- + +## 1. Главный экран (Главная) + +Точка входа в приложение. Сводка текущего дня, рекомендации и быстрый доступ к ключевым действиям. + +``` +┌─────────────────────────────────────┐ +│ FoodAI [аватар] │ +│ Привет, Алексей! │ +├─────────────────────────────────────┤ +│ │ +│ ┌───────────────────────────────┐ │ +│ │ Калории сегодня │ │ +│ │ │ │ +│ │ ◯◯◯◯◯◯◯◯◯ │ │ +│ │ (круговой прогресс-бар) │ │ +│ │ │ │ +│ │ 1 240 / 2 100 ккал │ │ +│ │ │ │ +│ │ Б: 68/120г Ж: 45/70г У: 150/260г │ +│ └───────────────────────────────┘ │ +│ │ +│ Быстрые действия │ +│ ┌─────────┐ ┌─────────┐ ┌───────┐ │ +│ │ [📷] │ │ [📷] │ │ [🔍] │ │ +│ │ Скан. │ │ Распозн.│ │ Найти │ │ +│ │ чека │ │ еду │ │рецепт │ │ +│ └─────────┘ └─────────┘ └───────┘ │ +│ │ +│ Рекомендуем приготовить │ +│ ┌──────────┐ ┌──────────┐ → │ +│ │ [фото] │ │ [фото] │ │ +│ │ Стир-фрай│ │ Омлет с │ │ +│ │ с курицей│ │ овощами │ │ +│ │ 350 ккал │ │ 240 ккал │ │ +│ │ Курица │ │ Яйца │ │ +│ │ истекает ⚠│ │ истекают⚠│ │ +│ └──────────┘ └──────────┘ │ +│ │ +│ Сегодня в меню │ +│ ┌───────────────────────────────┐ │ +│ │ Завтрак 320 ккал │ │ +│ │ Овсянка с ягодами [✓] │ │ +│ ├───────────────────────────────┤ │ +│ │ Обед 540 ккал │ │ +│ │ Куриная грудка с рисом [✓] │ │ +│ ├───────────────────────────────┤ │ +│ │ Ужин — ккал │ │ +│ │ Не запланирован │ │ +│ │ [+ Подобрать блюдо] │ │ +│ ├───────────────────────────────┤ │ +│ │ Перекус 180 ккал │ │ +│ │ Греческий йогурт [✓] │ │ +│ └───────────────────────────────┘ │ +│ │ +│ Готовили недавно │ +│ ┌────────┐ ┌────────┐ ┌────────┐ │ +│ │[фото] │ │[фото] │ │[фото] │ │ +│ │Карбон. │ │Цезарь │ │Борщ │ │ +│ └────────┘ └────────┘ └────────┘ │ +│ │ +│ Скоро испортятся │ +│ ┌───────────────────────────────┐ │ +│ │ ⚠ Молоко — осталось 2 дня │ │ +│ │ ⚠ Куриное филе — завтра │ │ +│ └───────────────────────────────┘ │ +│ │ +├─────────────────────────────────────┤ +│ [Главная] [Продукты] [Меню] [Рецепты] [Профиль] │ +└─────────────────────────────────────┘ +``` + +### Пустое состояние главного экрана (новый пользователь) + +``` +┌─────────────────────────────────────┐ +│ FoodAI [аватар] │ +│ Привет! │ +├─────────────────────────────────────┤ +│ │ +│ [Иллюстрация] │ +│ │ +│ Начните с добавления продуктов, │ +│ чтобы мы подобрали рецепты │ +│ и составили меню для вас │ +│ │ +│ ┌───────────────────────────────┐ │ +│ │ 📷 Сфотографировать продукты │ │ +│ └───────────────────────────────┘ │ +│ ┌───────────────────────────────┐ │ +│ │ 🧾 Сканировать чек │ │ +│ └───────────────────────────────┘ │ +│ ┌───────────────────────────────┐ │ +│ │ 🔍 Посмотреть рецепты │ │ +│ └───────────────────────────────┘ │ +│ │ +├─────────────────────────────────────┤ +│ [Главная] [Продукты] [Меню] [Рецепты] [Профиль] │ +└─────────────────────────────────────┘ +``` + +### Элементы и поведение + +- **Шапка:** название приложения слева, аватар пользователя справа (тап — переход в профиль). Приветствие с именем пользователя. +- **Карточка калорий:** круговой прогресс-бар, показывающий отношение потреблённых калорий к цели. Под ним — три мини-прогресс-бара для БЖУ. Тап по карточке — переход в дневник питания. Если калории превышены — прогресс-бар красный. +- **Быстрые действия:** три кнопки в ряд, оформленные как квадратные карточки с иконкой и подписью. «Сканировать чек» — открывает камеру в режиме сканирования чека. «Распознать еду» — открывает камеру в режиме распознавания блюда/продуктов. «Найти рецепт» — переход в каталог рецептов. +- **Рекомендуем приготовить:** горизонтальная карусель из 3–5 карточек рецептов. Алгоритм приоритизации: (1) рецепты из продуктов с истекающим сроком, (2) рецепты, полностью покрываемые запасами, (3) рецепты по предпочтениям кухни пользователя. На карточке — пометка, какой продукт нужно использовать. Тап — переход в карточку рецепта. Секция не показывается, если нет продуктов в запасах. +- **Сегодня в меню:** список запланированных приёмов пищи. Каждый пункт — название приёма, название блюда, калорийность. Чекбокс справа — отметка «съедено» (данные добавляются в дневник). Если приём пищи не запланирован — кнопка «+ Подобрать блюдо» (открывает каталог рецептов с предустановленным фильтром по типу приёма и остатку калорий на день). Тап по блюду — переход в карточку рецепта. +- **Готовили недавно:** горизонтальный ряд из 3–5 последних приготовленных рецептов для быстрого повтора. Тап — переход в карточку рецепта. Секция не показывается, если нет истории. +- **Скоро испортятся:** предупреждения о продуктах с истекающим сроком. Показываются только при наличии таких продуктов. Тап — переход на экран «Мои продукты» с фильтром по сроку. +- **Пустое состояние:** для нового пользователя без продуктов и меню — мотивирующий экран с основными действиями для старта. + +--- + +## 2. Мои продукты + +Учёт всех имеющихся продуктов. Добавление через фото, чек или вручную. + +``` +┌─────────────────────────────────────┐ +│ Мои продукты [+ ▾] [···] │ +├─────────────────────────────────────┤ +│ ┌─────────────────────────────────┐ │ +│ │ 🔍 Поиск продуктов... │ │ +│ └─────────────────────────────────┘ │ +│ │ +│ [Все] [Истекает срок] [Молочные] [Мясо] [Овощи]..│ +│ │ +│ Молочные продукты │ +│ ┌───────────────────────────────┐ │ +│ │ 🥛 Молоко 2.5% │ │ +│ │ 1 л · осталось 2 дня ⚠ │ │ +│ ├───────────────────────────────┤ │ +│ │ 🧀 Сыр Гауда │ │ +│ │ 300 г · осталось 10 дней │ │ +│ ├───────────────────────────────┤ │ +│ │ 🫙 Йогурт греческий │ │ +│ │ 2 шт · осталось 5 дней │ │ +│ └───────────────────────────────┘ │ +│ │ +│ Мясо и птица │ +│ ┌───────────────────────────────┐ │ +│ │ 🍗 Куриное филе │ │ +│ │ 500 г · осталось 1 день ⚠ │ │ +│ ├───────────────────────────────┤ │ +│ │ 🥩 Говядина │ │ +│ │ 400 г · осталось 13 дней │ │ +│ └───────────────────────────────┘ │ +│ │ +│ Овощи и фрукты │ +│ ┌───────────────────────────────┐ │ +│ │ 🥕 Морковь │ │ +│ │ 5 шт · без срока │ │ +│ ├───────────────────────────────┤ │ +│ │ 🍅 Помидоры │ │ +│ │ 4 шт · осталось 4 дня │ │ +│ └───────────────────────────────┘ │ +│ │ +│ ... │ +│ │ +├─────────────────────────────────────┤ +│ [Главная] [Продукты] [Меню] [Рецепты] [Профиль] │ +└─────────────────────────────────────┘ +``` + +### Выпадающее меню добавления [+ ▾] + +``` +┌───────────────────────┐ +│ 📷 Сфотографировать │ +│ продукты │ +├───────────────────────┤ +│ 🧾 Сканировать чек │ +├───────────────────────┤ +│ ✏️ Добавить вручную │ +└───────────────────────┘ +``` + +### Контекстное меню экрана [···] + +``` +┌─────────────────────────────┐ +│ 🔄 Очистить и перезаполнить │ +├─────────────────────────────┤ +│ ⚙️ Сроки хранения по │ +│ умолчанию │ +├─────────────────────────────┤ +│ 🗑 Удалить все просроченные │ +└─────────────────────────────┘ +``` + +### Экран редактирования продукта (тап по элементу) + +``` +┌─────────────────────────────────────┐ +│ [✕] Редактирование продукта │ +├─────────────────────────────────────┤ +│ │ +│ Название │ +│ ┌───────────────────────────────┐ │ +│ │ Молоко 2.5% │ │ +│ └───────────────────────────────┘ │ +│ │ +│ Количество Единица │ +│ ┌─────────────────┐ ┌─────────┐ │ +│ │ 1 │ │ л ▾ │ │ +│ └─────────────────┘ └─────────┘ │ +│ │ +│ Категория │ +│ ┌───────────────────────────────┐ │ +│ │ Молочные продукты ▾ │ │ +│ └───────────────────────────────┘ │ +│ │ +│ Срок хранения (дней после покупки) │ +│ ┌───────────────────────────────┐ │ +│ │ 5 │ │ +│ └───────────────────────────────┘ │ +│ Добавлено: 13.02.2026 │ +│ Годен до: 18.02.2026 (2 дня) │ +│ │ +│ ┌───────────────────────────────┐ │ +│ │ Сохранить │ │ +│ └───────────────────────────────┘ │ +│ │ +│ [Использовано частично] │ +│ [Удалить продукт] │ +│ │ +└─────────────────────────────────────┘ +``` + +### Модальное окно «Использовано частично» + +``` +┌───────────────────────────────────┐ +│ Сколько осталось? │ +├───────────────────────────────────┤ +│ │ +│ Было: 1 л │ +│ │ +│ Осталось: │ +│ ┌─────────────────┐ ┌───────┐ │ +│ │ 0.5 │ │ л ▾ │ │ +│ └─────────────────┘ └───────┘ │ +│ │ +│ ┌───────────────────────────┐ │ +│ │ Сохранить │ │ +│ └───────────────────────────┘ │ +└───────────────────────────────────┘ +``` + +### Подтверждение «Очистить и перезаполнить» + +``` +┌───────────────────────────────────┐ +│ Очистить все продукты? │ +├───────────────────────────────────┤ +│ │ +│ Все текущие продукты будут │ +│ удалены. После этого вы сможете │ +│ добавить продукты заново. │ +│ │ +│ ┌───────────────────────────────┐ │ +│ │ Очистить и сфотографировать │ │ +│ └───────────────────────────────┘ │ +│ ┌───────────────────────────────┐ │ +│ │ Очистить и сканировать чек │ │ +│ └───────────────────────────────┘ │ +│ ┌───────────────────────────────┐ │ +│ │ Просто очистить │ │ +│ └───────────────────────────────┘ │ +│ │ +│ Отмена │ +│ │ +└───────────────────────────────────┘ +``` + +### Пустое состояние + +``` +┌─────────────────────────────────────┐ +│ Мои продукты [+ ▾] │ +├─────────────────────────────────────┤ +│ │ +│ │ +│ [Иллюстрация: │ +│ пустой холодильник] │ +│ │ +│ У вас пока нет продуктов │ +│ │ +│ Сфотографируйте холодильник │ +│ или сканируйте чек, чтобы │ +│ добавить первые продукты │ +│ │ +│ ┌───────────────────────────────┐ │ +│ │ 📷 Сфотографировать продукты │ │ +│ └───────────────────────────────┘ │ +│ ┌───────────────────────────────┐ │ +│ │ 🧾 Сканировать чек │ │ +│ └───────────────────────────────┘ │ +│ │ +├─────────────────────────────────────┤ +│ [Главная] [Продукты] [Меню] [Рецепты] [Профиль] │ +└─────────────────────────────────────┘ +``` + +### Элементы и поведение + +- **Шапка:** заголовок «Мои продукты», кнопка «+» с выпадающим меню добавления, кнопка «···» с контекстным меню экрана. +- **Строка поиска:** фильтрация списка продуктов по названию. +- **Горизонтальный ряд фильтров:** скроллируемые chip-кнопки — «Все», «Истекает срок», категории продуктов. Chip «Истекает срок» выделен предупреждающим цветом при наличии таких продуктов. +- **Список продуктов:** сгруппирован по категориям с заголовками-разделителями. Каждый элемент — иконка/эмодзи категории, название, количество с единицей измерения, оставшийся срок хранения («осталось X дней»). Значок ⚠ — срок истекает в ближайшие 3 дня. Для продуктов без срока (крупы, макароны) — «без срока». +- **Модель срока годности:** используется «период хранения после покупки» вместо фиксированной даты. При добавлении продукта автоматически подставляется срок по умолчанию для категории (молоко — 5 дней, курица — 3 дня, и т.д.). Пользователь может скорректировать период для конкретного продукта. Дата окончания вычисляется: дата добавления + период хранения. Значения по умолчанию настраиваются в профиле. +- **Свайп по элементу влево:** открывает кнопки «Изменить» и «Удалить». +- **Тап по элементу:** открывает экран редактирования продукта с полями: название, количество, единица измерения (г/кг/мл/л/шт/пучок/упаковка), категория, период хранения. Отображается дата добавления и вычисленная дата окончания. Кнопка «Использовано частично» — открывает модальное окно для указания оставшегося количества. Кнопка «Удалить продукт» — удаляет с подтверждением. +- **Очистить и перезаполнить:** удаляет все продукты и сразу предлагает способ добавления заново (фото, чек или ручной ввод). Требует подтверждения. После фото/чека — стандартный флоу распознавания с корректировкой. +- **Сроки хранения по умолчанию:** переход к экрану настроек сроков (списком по категориям: молочные — 5 дней, мясо — 3 дня, и т.д.). Можно изменить значения. +- **Удалить все просроченные:** удаляет все продукты с истёкшим сроком (с подтверждением). +- **Дубликаты:** при добавлении продукта, который уже есть в списке, система показывает диалог: «Молоко 2.5% уже есть (1 л, осталось 2 дня). Объединить количество или добавить отдельно?» + +--- + +## 3. Экран камеры (Сканирование чека) + +Открывается при выборе «Сканировать чек». + +``` +┌─────────────────────────────────────┐ +│ [✕] Сканирование чека │ +├─────────────────────────────────────┤ +│ │ +│ │ +│ ┌───────────────────┐ │ +│ │ │ │ +│ │ │ │ +│ │ Область камеры │ │ +│ │ │ │ +│ │ Наведите камеру │ │ +│ │ на чек │ │ +│ │ │ │ +│ │ │ │ +│ └───────────────────┘ │ +│ │ +│ │ +│ ┌──────────┐ │ +│ │ ◉ │ │ +│ │ Снять │ │ +│ └──────────┘ │ +│ │ +│ [🖼 Из галереи] │ +│ │ +└─────────────────────────────────────┘ +``` + +### Элементы и поведение + +- **Шапка:** кнопка закрытия «✕» слева, заголовок по центру. +- **Видоискатель:** занимает основную часть экрана. Рамка-подсказка для позиционирования чека. +- **Кнопка съёмки:** крупная круглая кнопка внизу по центру. +- **Кнопка «Из галереи»:** выбор уже сделанного фото из галереи телефона. +- После съёмки — экран загрузки с анимацией AI-распознавания, затем переход к экрану результатов. +- **Ошибка распознавания:** если фото слишком размытое или чек не читаем — сообщение «Не удалось распознать чек. Попробуйте сфотографировать ещё раз или добавьте продукты вручную» с кнопками «Переснять» и «Ввести вручную». + +--- + +## 4. Результат распознавания чека / фото продуктов + +Общий экран для результатов распознавания чека и фото продуктов. Каждый продукт можно детально скорректировать. + +``` +┌─────────────────────────────────────┐ +│ [←] Распознанные продукты │ +├─────────────────────────────────────┤ +│ │ +│ Найдено 6 продуктов │ +│ │ +│ ┌───────────────────────────────┐ │ +│ │ [✓] Молоко 2.5% │ │ +│ │ 1 [л ▾] · Молочные │ │ +│ │ Хранение: 5 дн. [✎] │ │ +│ ├───────────────────────────────┤ │ +│ │ [✓] Куриное филе │ │ +│ │ 500 [г ▾] · Мясо │ │ +│ │ Хранение: 3 дн. [✎] │ │ +│ ├───────────────────────────────┤ │ +│ │ [✓] Помидоры │ │ +│ │ 4 [шт ▾] · Овощи │ │ +│ │ Хранение: 7 дн. [✎] │ │ +│ ├───────────────────────────────┤ │ +│ │ [✓] Рис Басмати │ │ +│ │ 800 [г ▾] · Крупы │ │ +│ │ Хранение: без срока [✎] │ │ +│ ├───────────────────────────────┤ │ +│ │ [✓] Сыр Гауда │ │ +│ │ 300 [г ▾] · Молочные │ │ +│ │ Хранение: 14 дн. [✎] │ │ +│ ├───────────────────────────────┤ │ +│ │ [?] Неизвестный товар → 49₽ │ │ +│ │ Нажмите, чтобы указать │ │ +│ └───────────────────────────────┘ │ +│ │ +│ ⚠ Молоко 2.5% уже есть в запасах │ +│ (1 л, осталось 2 дня) │ +│ [Объединить] [Добавить отдельно] │ +│ │ +│ + Добавить продукт вручную │ +│ + Сделать ещё фото │ +│ │ +│ ┌───────────────────────────────┐ │ +│ │ Добавить в мои продукты │ │ +│ └───────────────────────────────┘ │ +│ │ +└─────────────────────────────────────┘ +``` + +### Инлайн-редактирование продукта (тап по [✎] или по строке) + +``` +┌───────────────────────────────────┐ +│ Редактировать │ +├───────────────────────────────────┤ +│ │ +│ Название │ +│ ┌───────────────────────────────┐ │ +│ │ Молоко 2.5% │ │ +│ └───────────────────────────────┘ │ +│ │ +│ Количество Единица │ +│ ┌────────────────┐ ┌───────────┐ │ +│ │ 1 │ │ л ▾ │ │ +│ └────────────────┘ └───────────┘ │ +│ │ +│ Категория │ +│ ┌───────────────────────────────┐ │ +│ │ Молочные продукты ▾ │ │ +│ └───────────────────────────────┘ │ +│ │ +│ Хранение (дней после покупки) │ +│ ┌───────────────────────────────┐ │ +│ │ 5 │ │ +│ └───────────────────────────────┘ │ +│ │ +│ ┌───────────────────────────────┐ │ +│ │ Сохранить │ │ +│ └───────────────────────────────┘ │ +│ │ +│ [Удалить из списка] │ +│ │ +└───────────────────────────────────┘ +``` + +### Элементы и поведение + +- **Шапка:** кнопка «Назад», заголовок. +- **Счётчик:** «Найдено N продуктов» — сколько позиций распознано. +- **Список продуктов:** каждый элемент содержит: + - Чекбокс — для включения/исключения из добавления (по умолчанию вкл). + - Название — редактируемое поле (тап → inline edit или bottom sheet). + - Количество и единица измерения — рядом, оба редактируемые. Единица — выпадающий список (г, кг, мл, л, шт, пучок, упаковка). Система подставляет наиболее вероятную единицу по категории. + - Категория — автоматически определённая, можно сменить. + - Период хранения — автоподставлен по категории, можно скорректировать. Для крупных категорий (крупы, макароны, специи) — «без срока». + - Кнопка «✎» — открывает bottom sheet с полной формой редактирования. +- **Нераспознанные товары:** отмечены значком «?». Тап — открывает форму ручного ввода. Если из чека — рядом отображается цена для подсказки. +- **Предупреждение о дубликатах:** если распознанный продукт уже есть в запасах — жёлтое предупреждение с вариантами: «Объединить» (прибавить количество к существующему), «Добавить отдельно» (новая позиция). +- **«+ Добавить продукт вручную»:** добавляет пустую строку для ручного ввода. +- **«+ Сделать ещё фото»:** открывает камеру для дополнительного снимка (несколько полок, второй чек). Результаты добавляются в текущий список. +- **CTA-кнопка «Добавить в мои продукты»:** добавляет все отмеченные продукты в запасы. После подтверждения — переход к переходному экрану «Составить меню?» (а не возврат на «Мои продукты»). + +--- + +## 5. Переходный экран «Составить меню?» + +Появляется после добавления продуктов (чек/фото). Плавный переход к составлению меню. + +``` +┌─────────────────────────────────────┐ +│ │ +│ ✅ 6 продуктов добавлено │ +│ │ +│ ┌───────────────────────────────┐ │ +│ │ │ │ +│ │ [Иллюстрация: повар │ │ +│ │ с тарелками] │ │ +│ │ │ │ +│ └───────────────────────────────┘ │ +│ │ +│ Составить меню из ваших продуктов? │ +│ │ +│ На какой период? │ +│ [На день] [На неделю●] │ +│ │ +│ Какая кухня? │ +│ [Любая●] [Русская] [Азиатская] │ +│ [Европейская] [Средиземноморская] │ +│ │ +│ Сложность │ +│ [Попроще●] [Средняя] [Посложнее] │ +│ │ +│ ┌───────────────────────────────┐ │ +│ │ Составить меню │ │ +│ └───────────────────────────────┘ │ +│ │ +│ Пропустить │ +│ │ +└─────────────────────────────────────┘ +``` + +### Элементы и поведение + +- **Подтверждение:** сообщение об успешном добавлении продуктов. +- **Период:** выбор chip-кнопкой. «На день» — меню на сегодня. «На неделю» — на ближайшие 7 дней. +- **Кухня:** chip-кнопки с множественным выбором. «Любая» — выбрано по умолчанию. Остальные варианты отсортированы по предпочтениям из профиля. +- **Сложность:** три уровня. Влияет на время приготовления и количество шагов в рецептах. +- **«Составить меню»:** генерирует меню с учётом параметров и переходит на экран Меню с результатом (см. п.8, автогенерация). +- **«Пропустить»:** переход на экран «Мои продукты» (стандартное поведение). +- Экран НЕ показывается при добавлении продукта вручную — только при массовом добавлении через фото/чек. + +--- + +## 6. Экран камеры (Распознавание еды) + +Открывается при выборе «Распознать еду» — для определения блюда и калорийности по фото. + +``` +┌─────────────────────────────────────┐ +│ [✕] Распознать еду │ +├─────────────────────────────────────┤ +│ │ +│ ┌───────────────────┐ │ +│ │ │ │ +│ │ │ │ +│ │ Область камеры │ │ +│ │ │ │ +│ │ Сфотографируйте │ │ +│ │ блюдо или │ │ +│ │ продукты │ │ +│ │ │ │ +│ └───────────────────┘ │ +│ │ +│ Режим: │ +│ [ Готовое блюдо ] [ Продукты ] │ +│ │ +│ ┌──────────┐ │ +│ │ ◉ │ │ +│ │ Снять │ │ +│ └──────────┘ │ +│ │ +│ [🖼 Из галереи] │ +│ │ +└─────────────────────────────────────┘ +``` + +### Элементы и поведение + +- Аналогично экрану сканирования чека, но с переключателем режима. +- **Режим «Готовое блюдо»:** после съёмки приложение определяет блюдо и показывает его калорийность (переход к экрану результата распознавания блюда). +- **Режим «Продукты»:** после съёмки приложение определяет продукты на фото и предлагает добавить их в запасы (переход к экрану корректировки, аналогичному п.4). Поддерживает мультифото (кнопка «+ Сделать ещё фото» на экране результатов). +- **Ошибка распознавания:** «Не удалось определить блюдо/продукты. Попробуйте ещё раз или введите вручную» с кнопками «Переснять» и «Ввести вручную». + +--- + +## 7. Результат распознавания блюда + +Отображается после фотографирования готового блюда. + +``` +┌─────────────────────────────────────┐ +│ [←] Результат │ +├─────────────────────────────────────┤ +│ │ +│ ┌───────────────────────────────┐ │ +│ │ │ │ +│ │ [Фото сделанное юзером] │ │ +│ │ │ │ +│ └───────────────────────────────┘ │ +│ │ +│ Паста Карбонара │ +│ ~450 г │ +│ │ +│ ┌───────────────────────────────┐ │ +│ │ 580 ккал │ │ +│ │ │ │ +│ │ Б: 24 г Ж: 28 г У: 56 г │ │ +│ └───────────────────────────────┘ │ +│ │ +│ Это верно? │ +│ ┌────────────┐ ┌──────────────┐ │ +│ │ Да, верно │ │ Нет, это... │ │ +│ └────────────┘ └──────────────┘ │ +│ │ +│ Размер порции │ +│ ○──────────────●───────────○ │ +│ 0.5x [1x] 1.5x 2x │ +│ 290 ккал 580 870 1160 │ +│ │ +│ Приём пищи: │ +│ [Завтр.] [Обед●] [Ужин] [Перекус] │ +│ │ +│ ┌───────────────────────────────┐ │ +│ │ Записать в дневник питания │ │ +│ └───────────────────────────────┘ │ +│ │ +│ Похожие рецепты: │ +│ ┌─────────┐ ┌─────────┐ │ +│ │ [фото] │ │ [фото] │ → │ +│ │ Карбон. │ │ Карбон. │ │ +│ │ классич.│ │ с бекон.│ │ +│ │ 520ккал │ │ 610ккал │ │ +│ └─────────┘ └─────────┘ │ +│ │ +└─────────────────────────────────────┘ +``` + +### Элементы и поведение + +- **Фото:** сделанный пользователем снимок блюда. +- **Название и вес:** определённое приложением название блюда и приблизительный вес порции. +- **Карточка калорийности:** калории и БЖУ крупным шрифтом. +- **Подтверждение:** «Да, верно» — подтверждает распознавание. «Нет, это...» — открывает поиск по названию блюда для ручной корректировки. После выбора — калорийность пересчитывается. +- **Размер порции:** слайдер от 0.5x до 2x с шагом 0.25. Калорийность и БЖУ пересчитываются при перемещении. Показывается числовое значение калорий для выбранного множителя. +- **Приём пищи:** chip-кнопки. Предвыбран ближайший по времени приём (до 10:00 — завтрак, 10:00–14:00 — обед, 14:00–17:00 — перекус, после 17:00 — ужин). +- **CTA-кнопка «Записать в дневник питания»:** добавляет блюдо в дневник выбранного дня и приёма пищи. +- **Похожие рецепты:** горизонтальная карусель карточек рецептов. Тап — переход в карточку рецепта. Полезно, если пользователь захочет приготовить это блюдо сам. + +--- + +## 8. Меню (Планирование) + +Календарное представление запланированных приёмов пищи. + +``` +┌─────────────────────────────────────┐ +│ Меню [⚡▾] [Список пок.]│ +├─────────────────────────────────────┤ +│ │ +│ ◁ 15 – 21 февраля 2026 ▷ │ +│ │ +│ Пн Вт Ср Чт Пт Сб Вс │ +│ 16 17 18 19 20 21 22 │ +│ ● ● ● ○ ○ ○ ○ │ +│ │ +│ ─── Понедельник, 16 февраля ─────── │ +│ │ +│ Итого: 1 840 / 2 100 ккал │ +│ ████████████████████░░░░ 88% │ +│ │ +│ Завтрак 320 ккал │ +│ ┌───────────────────────────────┐ │ +│ │ [фото] Овсянка с ягодами │ │ +│ │ и мёдом [···] │ │ +│ └───────────────────────────────┘ │ +│ │ +│ Обед 640 ккал │ +│ ┌───────────────────────────────┐ │ +│ │ [фото] Куриная грудка │ │ +│ │ с рисом и овощами │ │ +│ │ [···] │ │ +│ └───────────────────────────────┘ │ +│ │ +│ Ужин │ +│ ┌───────────────────────────────┐ │ +│ │ + Подобрать блюдо │ │ +│ │ Осталось 880 ккал на день │ │ +│ │ Рекомендуем: ужин из курицы │ │ +│ │ и овощей (срок курицы ⚠) │ │ +│ └───────────────────────────────┘ │ +│ │ +│ Перекус 300 ккал │ +│ ┌───────────────────────────────┐ │ +│ │ [фото] Греческий йогурт │ │ +│ │ с гранолой [···] │ │ +│ └───────────────────────────────┘ │ +│ │ +│ [+ Добавить приём пищи] │ +│ │ +├─────────────────────────────────────┤ +│ [Главная] [Продукты] [Меню] [Рецепты] [Профиль] │ +└─────────────────────────────────────┘ +``` + +### Кнопка автогенерации [⚡▾] + +``` +┌───────────────────────────────┐ +│ ⚡ Сгенерировать меню │ +├───────────────────────────────┤ +│ 📋 Из шаблона │ +├───────────────────────────────┤ +│ 🕐 Из истории меню │ +├───────────────────────────────┤ +│ 💾 Сохранить как шаблон │ +└───────────────────────────────┘ +``` + +### Экран автогенерации меню + +``` +┌─────────────────────────────────────┐ +│ [←] Генерация меню │ +├─────────────────────────────────────┤ +│ │ +│ Период │ +│ [Сегодня] [Неделя●] [2 недели] │ +│ │ +│ Кухня (можно несколько) │ +│ [Любая] [Русская✓] [Азиатская✓] │ +│ [Европ.] [Средизем.] [Кавказская] │ +│ │ +│ Сложность │ +│ [Попроще] [Средняя●] [Посложнее] │ +│ │ +│ Учитывать мои продукты │ +│ [Вкл ●] [Выкл] │ +│ │ +│ Калорий в день │ +│ ┌───────────────────────────────┐ │ +│ │ 2 100 (рекомендуемая) │ │ +│ └───────────────────────────────┘ │ +│ │ +│ ┌───────────────────────────────┐ │ +│ │ Сгенерировать │ │ +│ └───────────────────────────────┘ │ +│ │ +└─────────────────────────────────────┘ +``` + +### Контекстное меню блюда [···] + +``` +┌───────────────────────┐ +│ 📖 Открыть рецепт │ +├───────────────────────┤ +│ 🔄 Заменить блюдо │ +├───────────────────────┤ +│ 📅 Перенести на др. │ +│ день │ +├───────────────────────┤ +│ 🗑 Убрать из меню │ +└───────────────────────┘ +``` + +### Пустое состояние + +``` +┌───────────────────────────────────┐ +│ │ +│ [Иллюстрация: календарь] │ +│ │ +│ Меню пока не составлено │ +│ │ +│ ┌─────────────────────────────┐ │ +│ │ ⚡ Сгенерировать на неделю │ │ +│ └─────────────────────────────┘ │ +│ ┌─────────────────────────────┐ │ +│ │ + Добавить блюда вручную │ │ +│ └─────────────────────────────┘ │ +│ │ +└───────────────────────────────────┘ +``` + +### Элементы и поведение + +- **Шапка:** заголовок «Меню», кнопка «⚡» — автогенерация и шаблоны, кнопка «Список покупок» — переход к автоматически сформированному списку. +- **Навигация по неделям:** стрелки влево/вправо для переключения между неделями. +- **Дни недели:** горизонтальный ряд с датами. Точка под датой — есть запланированные блюда. Тап по дню — прокрутка к содержимому этого дня. +- **Суммарная калорийность дня:** прогресс-бар с числами. Если превышена цель — красный. +- **Приёмы пищи:** разделены заголовками (Завтрак, Обед, Ужин, Перекус). Каждое блюдо — карточка с миниатюрой фото и названием. Кнопка «···» — контекстное меню. +- **Пустые слоты:** вместо пустой строки — подсказка с рекомендацией: «+ Подобрать блюдо · Осталось N ккал на день» и контекстная рекомендация (если есть продукты с истекающим сроком). Тап — переход в каталог рецептов с фильтрами: тип приёма пищи + остаток калорий + продукты с истекающим сроком. +- **Long press по блюду:** активирует drag-and-drop для перетаскивания между приёмами пищи и днями. +- **«+ Добавить приём пищи»:** добавляет дополнительный слот (второй перекус и т.д.). +- **«Заменить блюдо»:** открывает каталог рецептов с предустановленными фильтрами (тот же тип приёма пищи, похожая калорийность). +- **Автогенерация:** экран с параметрами. После генерации — отображается сгенерированное меню, каждое блюдо можно заменить или сгенерировать заново (кнопка «Перегенерировать»). +- **Шаблоны:** сохранение текущего меню как шаблона с названием. Загрузка шаблона — перезаписывает меню на выбранный период. +- **История:** список прошлых меню по неделям. Тап — подгружает меню на текущую неделю. + +--- + +## 9. Каталог рецептов + +Поиск и просмотр рецептов с фильтрацией и персональными рекомендациями. + +``` +┌─────────────────────────────────────┐ +│ Рецепты │ +├─────────────────────────────────────┤ +│ ┌─────────────────────────────────┐ │ +│ │ 🔍 Найти рецепт... │ │ +│ └─────────────────────────────────┘ │ +│ │ +│ [Из моих продуктов] [Фильтры ▾] │ +│ │ +│ Для вас │ +│ ┌──────────┐ ┌──────────┐ → │ +│ │ [фото] │ │ [фото] │ │ +│ │ Том Ям │ │ Пад Тай │ │ +│ │ ★4.8 │ │ ★4.6 │ │ +│ │ 320 ккал │ │ 450 ккал │ │ +│ │ Есть всё✓│ │ -2 прод. │ │ +│ └──────────┘ └──────────┘ │ +│ │ +│ Готовили недавно │ +│ ┌────────┐ ┌────────┐ ┌────────┐ │ +│ │[фото] │ │[фото] │ │[фото] │ │ +│ │Карбон. │ │Борщ │ │Цезарь │ │ +│ └────────┘ └────────┘ └────────┘ │ +│ │ +│ Все рецепты │ +│ ┌──────────┐ ┌──────────┐ │ +│ │ [фото] │ │ [фото] │ │ +│ │ │ │ │ │ +│ │ Ризотто │ │ Борщ │ │ +│ │ ★4.5 │ │ ★4.9 │ │ +│ │ 480 ккал │ │ 350 ккал │ │ +│ │ 50 мин │ │ 90 мин │ │ +│ │ Сложная │ │ Средняя │ │ +│ └──────────┘ └──────────┘ │ +│ │ +│ ┌──────────┐ ┌──────────┐ │ +│ │ ... │ │ ... │ │ +│ └──────────┘ └──────────┘ │ +│ │ +├─────────────────────────────────────┤ +│ [Главная] [Продукты] [Меню] [Рецепты] [Профиль] │ +└─────────────────────────────────────┘ +``` + +### Панель фильтров (раскрывается по тапу «Фильтры ▾») + +``` +┌─────────────────────────────────────┐ +│ Фильтры [Сброс]│ +├─────────────────────────────────────┤ +│ │ +│ Приём пищи │ +│ [Завтрак] [Обед] [Ужин] [Перекус] │ +│ │ +│ Кухня │ +│ [Русская] [Азиатская] [Европейская] │ +│ [Средиземноморская] [Американская] │ +│ │ +│ Сложность │ +│ [Простая] [Средняя] [Сложная] │ +│ │ +│ Время приготовления │ +│ [до 15 мин] [до 30 мин] [до 60 мин]│ +│ [более 60 мин] │ +│ │ +│ Калорийность (на порцию) │ +│ ○────────────● до 500 ккал │ +│ │ +│ Диета │ +│ [Вегетар.] [Безглютен.] [Низкокал.] │ +│ [Кето] [Без лактозы] │ +│ │ +│ ┌───────────────────────────────┐ │ +│ │ Показать 24 рецепта │ │ +│ └───────────────────────────────┘ │ +└─────────────────────────────────────┘ +``` + +### Элементы и поведение + +- **Строка поиска:** текстовый поиск по названию рецепта и ингредиентам. +- **Кнопка «Из моих продуктов»:** toggle-фильтр. При включении показывает только рецепты, которые можно приготовить из имеющихся продуктов (полностью или частично). Рецепты сортируются по доле имеющихся ингредиентов. На каждой карточке — пометка: «Есть всё ✓» или «-N прод.». +- **Кнопка «Фильтры»:** раскрывает панель фильтров (bottom sheet). Фильтры — chip-кнопки с множественным выбором. Слайдер для калорийности. Кнопка «Показать N рецептов» — применяет фильтры и закрывает панель. Кнопка «Сброс» — очищает все фильтры. +- **Секция «Для вас»:** горизонтальная карусель. Персональные рекомендации на основе: предпочтений кухонь, истории оценок, имеющихся продуктов (особенно с истекающим сроком). Алгоритм: продукты с истекающим сроком > полное совпадение ингредиентов > предпочтения кухни > высокий рейтинг. +- **Секция «Готовили недавно»:** горизонтальный ряд из последних 5 приготовленных рецептов. Быстрый доступ для повтора. Не показывается, если нет истории. +- **Секция «Все рецепты»:** сетка 2 колонки. Каждая карточка — фото, название, рейтинг (звёзды), калорийность, время, сложность. Тап — переход в карточку рецепта. +- **Бесконечный скролл:** подгрузка рецептов по мере прокрутки. + +--- + +## 10. Карточка рецепта + +Детальная информация о рецепте. + +``` +┌─────────────────────────────────────┐ +│ [←] [♡] [⤴] │ +├─────────────────────────────────────┤ +│ │ +│ ┌───────────────────────────────┐ │ +│ │ │ │ +│ │ [Фото блюда] │ │ +│ │ │ │ +│ └───────────────────────────────┘ │ +│ │ +│ Паста Карбонара │ +│ ★★★★☆ 4.6 (128 отзывов) │ +│ │ +│ ┌────────┐ ┌────────┐ ┌────────┐ │ +│ │ 30 мин │ │Средняя │ │ Европ. │ │ +│ │ Время │ │Сложн. │ │ Кухня │ │ +│ └────────┘ └────────┘ └────────┘ │ +│ │ +│ ┌───────────────────────────────┐ │ +│ │ 580 ккал на порцию │ │ +│ │ Б: 24г · Ж: 28г · У: 56г │ │ +│ └───────────────────────────────┘ │ +│ │ +│ Порций: [-] 2 [+] │ +│ │ +│ Ингредиенты │ +│ ┌───────────────────────────────┐ │ +│ │ ✅ Спагетти 200 г │ │ +│ │ ✅ Бекон/панчетта 150 г │ │ +│ │ ✅ Яйца 3 шт │ │ +│ │ ✅ Пармезан 80 г │ │ +│ │ 🔄 Пекорино 40 г │ │ +│ │ → Замена: Пармезан (есть) │ │ +│ │ ✅ Чёрный перец по вкусу │ │ +│ └───────────────────────────────┘ │ +│ Всё есть (с учётом замены) │ +│ │ +│ Описание │ +│ Классическое итальянское блюдо │ +│ из Рима. Настоящая карбонара │ +│ готовится без сливок — только │ +│ яйца, сыр и бекон. │ +│ │ +│ ┌───────────────────────────────┐ │ +│ │ 🍳 Начать готовить │ │ +│ └───────────────────────────────┘ │ +│ ┌───────────────────────────────┐ │ +│ │ 📅 Добавить в меню │ │ +│ └───────────────────────────────┘ │ +│ │ +│ Отзывы (128) [Написать] │ +│ ┌───────────────────────────────┐ │ +│ │ Мария ★★★★★ │ │ +│ │ Отличный рецепт! Готовила │ │ +│ │ уже 3 раза, всегда получается.│ │ +│ │ 2 дня назад │ │ +│ ├───────────────────────────────┤ │ +│ │ Дмитрий ★★★★☆ │ │ +│ │ Вкусно, но сложно с яичной │ │ +│ │ смесью — первый раз свернул. │ │ +│ │ 1 неделю назад │ │ +│ └───────────────────────────────┘ │ +│ [Показать все отзывы →] │ +│ │ +└─────────────────────────────────────┘ +``` + +### Модальное окно «Добавить в меню» + +``` +┌───────────────────────────────────┐ +│ Добавить в меню │ +├───────────────────────────────────┤ +│ │ +│ День: │ +│ [Пн 16] [Вт 17] [Ср 18] ... │ +│ │ +│ Приём пищи: │ +│ ○ Завтрак │ +│ ○ Обед │ +│ ● Ужин │ +│ ○ Перекус │ +│ │ +│ ┌───────────────────────────────┐ │ +│ │ Добавить │ │ +│ └───────────────────────────────┘ │ +└───────────────────────────────────┘ +``` + +### Модальное окно «Написать отзыв» + +``` +┌───────────────────────────────────┐ +│ Ваш отзыв [Отправить] │ +├───────────────────────────────────┤ +│ │ +│ Оценка: │ +│ ★ ★ ★ ★ ☆ │ +│ │ +│ ┌───────────────────────────────┐ │ +│ │ Напишите ваш отзыв... │ │ +│ │ │ │ +│ │ │ │ +│ └───────────────────────────────┘ │ +│ │ +│ [📷 Добавить фото] │ +│ │ +└───────────────────────────────────┘ +``` + +### Элементы и поведение + +- **Шапка:** кнопка «Назад», кнопка «В избранное» (♡ / ♥), кнопка «Поделиться» (⤴). +- **Фото блюда:** крупное фото в верхней части, может быть каруселью из нескольких фото. +- **Рейтинг:** звёзды, средняя оценка, количество отзывов. Тап — скролл к секции отзывов. +- **Метаинформация:** три chip-блока — время приготовления, сложность, тип кухни. +- **Калорийность:** карточка с калориями и БЖУ на одну порцию. +- **Регулятор порций:** «−» и «+» изменяют количество порций. Вес ингредиентов и калорийность пересчитываются пропорционально. +- **Ингредиенты с заменами:** список с отметками наличия: ✅ — есть в запасах, ❌ — нет в запасах, 🔄 — нет, но есть замена. Для ингредиентов с заменой — под основным продуктом строка «→ Замена: [продукт] (есть/нет)». Список возможных замен формируется системой (пекорино → пармезан, сливки → сметана и т.д.). Итог: «Всё есть», «Всё есть (с учётом замены)», «Не хватает: N продуктов» + кнопка «Добавить в список покупок». +- **Описание:** текстовое описание рецепта. +- **CTA-кнопка «Начать готовить»:** основная, акцентного цвета. Переход в режим пошаговой готовки. +- **Кнопка «Добавить в меню»:** открывает модальное окно выбора дня и приёма пищи. +- **Отзывы:** последние 2–3 отзыва. Каждый — имя, звёзды, текст, дата. Кнопка «Написать» — открывает модальное окно. «Показать все отзывы» — переход к полному списку. + +--- + +## 11. Режим готовки + +Пошаговый интерфейс для приготовления блюда с таймерами. + +``` +┌─────────────────────────────────────┐ +│ [✕] Паста Карбонара Шаг 3 из 7 │ +├─────────────────────────────────────┤ +│ │ +│ ┌───────────────────────────────┐ │ +│ │ │ │ +│ │ [Фото / иллюстрация │ │ +│ │ текущего шага] │ │ +│ │ │ │ +│ └───────────────────────────────┘ │ +│ │ +│ Отварите спагетти │ +│ │ +│ Вскипятите большую кастрюлю воды, │ +│ посолите. Опустите спагетти и │ +│ варите согласно инструкции на │ +│ упаковке (обычно 8–10 минут) │ +│ до состояния аль денте. │ +│ │ +│ ┌───────────────────────────────┐ │ +│ │ │ │ +│ │ ⏱ 10:00 │ │ +│ │ │ │ +│ │ [ Запустить таймер ] │ │ +│ │ │ │ +│ └───────────────────────────────┘ │ +│ │ +│ │ +│ │ +│ ┌──────────┐ ┌─────────────┐ │ +│ │ ◁ Назад │ │ Далее ▷ │ │ +│ └──────────┘ └─────────────┘ │ +│ │ +│ ● ● ● ○ ○ ○ ○ (индикатор шагов) │ +│ │ +│ ┌───────────────────────────────────┐│ +│ │ Активные таймеры: ││ +│ │ ⏱ Бульон: 34:12 [⏸] ││ +│ │ ⏱ Спагетти: 08:45 [⏸] ││ +│ └───────────────────────────────────┘│ +└─────────────────────────────────────┘ +``` + +### Состояние таймера (запущен) + +``` + ┌───────────────────────────────┐ + │ │ + │ ⏱ 07:23 │ + │ (обратный отсчёт) │ + │ │ + │ [ ⏸ Пауза ] [ ■ Стоп ] │ + │ │ + └───────────────────────────────┘ +``` + +### Уведомление о завершении таймера + +``` +┌─────────────────────────────────────┐ +│ │ +│ ┌───────────────────────────────┐ │ +│ │ ✅ Спагетти готовы! │ │ +│ │ │ │ +│ │ Таймер «10 мин» завершён. │ │ +│ │ │ │ +│ │ [ Понятно ] │ │ +│ └───────────────────────────────┘ │ +│ │ +└─────────────────────────────────────┘ +``` + +### Экран завершения готовки (последний шаг → «Готово!») + +``` +┌─────────────────────────────────────┐ +│ Приятного аппетита! │ +├─────────────────────────────────────┤ +│ │ +│ [Иллюстрация] │ +│ │ +│ Паста Карбонара готова! │ +│ 580 ккал · 2 порции │ +│ │ +│ ┌───────────────────────────────┐ │ +│ │ 📝 Записать в дневник │ │ +│ └───────────────────────────────┘ │ +│ │ +│ Сколько порций вы съели? │ +│ [-] 1 [+] │ +│ │ +│ ┌───────────────────────────────┐ │ +│ │ ★ Оценить рецепт │ │ +│ └───────────────────────────────┘ │ +│ ┌───────────────────────────────┐ │ +│ │ 📷 Поделиться фото │ │ +│ └───────────────────────────────┘ │ +│ │ +│ [Закрыть] │ +│ │ +└─────────────────────────────────────┘ +``` + +### Элементы и поведение + +- **Шапка:** кнопка закрытия «✕» (с подтверждением «Прервать готовку?»), название рецепта, текущий шаг из общего числа. +- **Фото/иллюстрация:** визуальная подсказка для текущего шага. Занимает верхнюю треть экрана. +- **Заголовок шага:** крупный жирный текст — краткое действие. +- **Описание шага:** подробная инструкция. Крупный шрифт для удобства чтения на кухне. +- **Блок таймера:** отображается только на шагах, где требуется ожидание. Показывает предустановленное время. Кнопка «Запустить таймер» — запускает обратный отсчёт. После запуска — кнопки «Пауза» и «Стоп». Таймер продолжает работать при переходе к другим шагам. +- **Навигация между шагами:** кнопки «Назад» и «Далее». Свайп влево/вправо для переключения. Точечный индикатор прогресса. +- **Панель активных таймеров:** фиксирована внизу экрана. Показывает все запущенные таймеры с оставшимся временем. Каждый таймер можно поставить на паузу. При завершении — push-уведомление + звуковой сигнал + модальное окно поверх текущего шага. +- **Экран не гаснет:** активен режим keep-screen-on. +- **Экран завершения:** после последнего шага кнопка «Далее» заменяется на «Готово!». По нажатию — экран завершения с действиями: + - «Записать в дневник» — добавляет блюдо в дневник. Можно выбрать количество съеденных порций. + - «Оценить рецепт» — открывает модалку отзыва. + - «Поделиться фото» — открывает камеру для фото результата, затем — системное меню «Поделиться». + - Ингредиенты автоматически списываются из запасов (если продукты были в списке «Мои продукты»). + +--- + +## 12. Дневник питания + +Учёт съеденного за день с подсчётом калорий и БЖУ. + +``` +┌─────────────────────────────────────┐ +│ Дневник питания │ +├─────────────────────────────────────┤ +│ │ +│ ◁ 15 февраля 2026 ▷ │ +│ │ +│ ┌───────────────────────────────┐ │ +│ │ 1 420 / 2 100 │ │ +│ │ ◯◯◯◯◯◯◯◯◯◯◯◯◯◯ │ │ +│ │ (круговой прогресс) │ │ +│ │ │ │ +│ │ Б: 82/120г Ж: 51/70г У: 178/260г │ +│ │ ████████░░ ███████░░ █████████░ │ +│ └───────────────────────────────┘ │ +│ │ +│ Завтрак 320 ккал │ +│ ┌───────────────────────────────┐ │ +│ │ Овсянка с ягодами │ │ +│ │ 1 порц. · 320 ккал │ │ +│ │ Б:12 Ж:8 У:52 [···] │ │ +│ └───────────────────────────────┘ │ +│ │ +│ Обед 640 ккал │ +│ ┌───────────────────────────────┐ │ +│ │ Куриная грудка с рисом │ │ +│ │ 1 порц. · 640 ккал │ │ +│ │ Б:45 Ж:18 У:72 [···] │ │ +│ └───────────────────────────────┘ │ +│ │ +│ Перекус 280 ккал │ +│ ┌───────────────────────────────┐ │ +│ │ Йогурт греческий │ │ +│ │ 1 порц. · 180 ккал │ │ +│ │ Б:15 Ж:10 У:12 [···] │ │ +│ ├───────────────────────────────┤ │ +│ │ Банан │ │ +│ │ 100 ккал │ │ +│ │ Б:1 Ж:0 У:26 [···] │ │ +│ └───────────────────────────────┘ │ +│ │ +│ Ужин │ +│ ┌───────────────────────────────┐ │ +│ │ + Добавить приём пищи │ │ +│ └───────────────────────────────┘ │ +│ │ +│ Вода: 💧💧💧💧💧○○○ 5/8 стаканов │ +│ │ +├─────────────────────────────────────┤ +│ [Главная] [Продукты] [Меню] [Рецепты] [Профиль] │ +└─────────────────────────────────────┘ +``` + +### Модальное окно «Добавить приём пищи» + +``` +┌───────────────────────────────────┐ +│ Добавить │ +├───────────────────────────────────┤ +│ │ +│ ┌───────────────────────────────┐ │ +│ │ 📷 Сфотографировать блюдо │ │ +│ └───────────────────────────────┘ │ +│ ┌───────────────────────────────┐ │ +│ │ 📅 Из сегодняшнего меню │ │ +│ └───────────────────────────────┘ │ +│ ┌───────────────────────────────┐ │ +│ │ 📖 Из каталога рецептов │ │ +│ └───────────────────────────────┘ │ +│ ┌───────────────────────────────┐ │ +│ │ ⭐ Из избранного │ │ +│ └───────────────────────────────┘ │ +│ ┌───────────────────────────────┐ │ +│ │ 🔍 Быстрый поиск продукта │ │ +│ └───────────────────────────────┘ │ +│ ┌───────────────────────────────┐ │ +│ │ ✏️ Ввести вручную │ │ +│ └───────────────────────────────┘ │ +│ │ +└───────────────────────────────────┘ +``` + +### Модальное окно «Указать порцию» (при добавлении из рецепта) + +``` +┌───────────────────────────────────┐ +│ Сколько вы съели? │ +├───────────────────────────────────┤ +│ │ +│ Паста Карбонара │ +│ 1 порция = 580 ккал │ +│ │ +│ Количество порций: │ +│ ○──────────●──────────○ │ +│ 0.5 [1] 1.5 2 │ +│ │ +│ = 580 ккал │ +│ Б: 24г · Ж: 28г · У: 56г │ +│ │ +│ ┌───────────────────────────────┐ │ +│ │ Добавить │ │ +│ └───────────────────────────────┘ │ +└───────────────────────────────────┘ +``` + +### Элементы и поведение + +- **Навигация по дням:** стрелки влево/вправо, тап по дате — открывает календарь. +- **Карточка итогов:** круговой прогресс калорий, три линейных прогресс-бара для БЖУ. Если калории превышены — красный прогресс-бар. +- **Приёмы пищи:** разделены по типу (Завтрак, Обед, Ужин, Перекус). Каждая запись — название блюда, количество порций, калории, БЖУ. Кнопка «···» — редактировать порцию, удалить запись (с отменой через toast). Пустой приём — кнопка «+ Добавить». +- **«+ Добавить приём пищи»:** открывает модальное окно с вариантами добавления: + - «Сфотографировать блюдо» — камера → распознавание → результат. + - «Из сегодняшнего меню» — список блюд из меню на сегодня. + - «Из каталога рецептов» — переход в каталог. + - «Из избранного» — список избранных рецептов. + - **«Быстрый поиск продукта»** — строка поиска по базе отдельных продуктов (банан, хлеб, кофе с молоком и т.д.) для быстрого добавления без рецепта. + - «Ввести вручную» — форма: название, калории, БЖУ (необязательно). +- **Указание порции:** при добавлении блюда из рецепта — модальное окно со слайдером от 0.5 до 3 порций. Калории и БЖУ пересчитываются. +- **Трекер воды:** ряд из 8 значков стаканов. Тап по пустому — отмечает выпитый стакан. Тап по заполненному — снимает отметку. Количество стаканов (норма) настраивается в профиле. +- Дневник доступен из главного экрана через тап по карточке калорий. + +--- + +## 13. Статистика + +Графики и аналитика питания за период. + +``` +┌─────────────────────────────────────┐ +│ Статистика │ +├─────────────────────────────────────┤ +│ │ +│ [Неделя] [Месяц] [3 месяца] │ +│ │ +│ Калории │ +│ ┌───────────────────────────────┐ │ +│ │ 2500 ┤ │ │ +│ │ ┤ ╭─╮ │ │ +│ │ 2100 ┤─ ─ ─ ┤ ├─╮─ ─ (цель) │ │ +│ │ ┤ ╭─╮ │ │ │ ╭─╮ │ │ +│ │ 1500 ┤╭─┤ ├─┤ │ ├─┤ ├─╮ │ │ +│ │ ┤│ │ │ │ │ │ │ │ │ │ │ +│ │ 0 ┤┴─┴─┴─┴─┴─┴─┴─┴─┘ │ │ +│ │ Пн Вт Ср Чт Пт Сб Вс │ │ +│ └───────────────────────────────┘ │ +│ │ +│ Среднее: 1 920 ккал/день │ +│ Цель: 2 100 ккал/день │ +│ │ +│ БЖУ (среднее за период) │ +│ ┌───────────────────────────────┐ │ +│ │ │ │ +│ │ ██████████████████████ │ │ +│ │ Б: 28% Ж: 32% У: 40% │ │ +│ │ │ │ +│ │ Рекомендация: │ │ +│ │ Б: 30% Ж: 25% У: 45% │ │ +│ └───────────────────────────────┘ │ +│ │ +│ Тренды │ +│ ┌───────────────────────────────┐ │ +│ │ ↓ Калории: -5% к прошл. нед. │ │ +│ │ ↑ Белок: +12% к прошл. нед. │ │ +│ │ → Жиры: без изменений │ │ +│ └───────────────────────────────┘ │ +│ │ +│ Самые частые блюда │ +│ ┌───────────────────────────────┐ │ +│ │ 1. Овсянка с ягодами — 5 раз │ │ +│ │ 2. Куриная грудка — 4 раза │ │ +│ │ 3. Греческий йогурт — 4 раза │ │ +│ └───────────────────────────────┘ │ +│ │ +├─────────────────────────────────────┤ +│ [Главная] [Продукты] [Меню] [Рецепты] [Профиль] │ +└─────────────────────────────────────┘ +``` + +### Элементы и поведение + +- **Переключатель периода:** chip-кнопки — «Неделя», «Месяц», «3 месяца». +- **График калорий:** столбчатая диаграмма. Пунктирная линия — цель. Столбцы выше цели выделены предупреждающим цветом. Тап по столбцу — показывает точное значение за день. +- **Средние значения:** числовые показатели — среднее потребление и цель. +- **Диаграмма БЖУ:** горизонтальный stacked bar. Показывает фактическое соотношение. Под ним — рекомендуемое соотношение для сравнения. +- **Тренды:** карточка с краткой аналитикой — изменения к предыдущему периоду. Стрелки вверх/вниз/вправо с цветовой кодировкой (зелёный — улучшение, красный — ухудшение, серый — без изменений). +- **Самые частые блюда:** рейтинг блюд за период. Тап — переход в карточку рецепта. +- Экран доступен из главного экрана через тап по прогресс-бару или из профиля. +- **Пустое состояние:** «Пока недостаточно данных для статистики. Записывайте приёмы пищи, и мы покажем аналитику» (показывается, если записей менее 3 дней). + +--- + +## 14. Список покупок + +Автоматически формируемый и вручную редактируемый список покупок. + +``` +┌─────────────────────────────────────┐ +│ [←] Список покупок [···] │ +├─────────────────────────────────────┤ +│ │ +│ На основе меню: 16–22 февраля │ +│ │ +│ Молочные продукты │ +│ ┌───────────────────────────────┐ │ +│ │ [ ] Пекорино 40 г │ │ +│ │ [ ] Сливки 20% 200 мл │ │ +│ │ [✓] Молоко 2.5% 1 л │ │ +│ └───────────────────────────────┘ │ +│ │ +│ Мясо и рыба │ +│ ┌───────────────────────────────┐ │ +│ │ [ ] Лосось 400 г │ │ +│ │ [ ] Бекон/панчетта 300 г │ │ +│ └───────────────────────────────┘ │ +│ │ +│ Овощи и фрукты │ +│ ┌───────────────────────────────┐ │ +│ │ [ ] Авокадо 2 шт │ │ +│ │ [ ] Лайм 3 шт │ │ +│ │ [ ] Кинза 1 пуч. │ │ +│ │ [✓] Лук репчатый 3 шт │ │ +│ └───────────────────────────────┘ │ +│ │ +│ Добавлено вручную │ +│ ┌───────────────────────────────┐ │ +│ │ [ ] Хлеб │ │ +│ │ [ ] Вода 5л │ │ +│ └───────────────────────────────┘ │ +│ │ +│ [+ Добавить продукт] │ +│ │ +│ Итого: 12 позиций (4 куплено) │ +│ │ +└─────────────────────────────────────┘ +``` + +### Контекстное меню [···] + +``` +┌───────────────────────────────────┐ +│ 🗑 Очистить купленные │ +├───────────────────────────────────┤ +│ 📤 Поделиться списком │ +├───────────────────────────────────┤ +│ 🔄 Пересчитать из меню │ +└───────────────────────────────────┘ +``` + +### Элементы и поведение + +- **Шапка:** кнопка «Назад», заголовок, кнопка «···» с контекстным меню. +- **Подзаголовок:** указание, на основе какого периода меню сформирован список. +- **Группировка:** продукты сгруппированы по категориям. Отдельная секция «Добавлено вручную» — для позиций, добавленных пользователем. +- **Чекбоксы:** тап — отмечает продукт как купленный. Купленные элементы перечёркиваются и сдвигаются вниз группы. +- **Свайп влево:** удалить позицию. +- **Тап по элементу:** редактирование (название, количество). +- **«+ Добавить продукт»:** ручное добавление позиции. +- **Итоговая строка:** общее количество позиций и сколько уже куплено. +- **«Очистить купленные»:** удаляет все отмеченные позиции. +- **«Поделиться списком»:** формирует текстовый список и открывает системное меню «Поделиться» (мессенджеры, заметки и т.д.). +- **«Пересчитать из меню»:** заново формирует список на основе текущего меню и запасов (с подтверждением, если есть ручные позиции). + +--- + +## 15. Профиль + +Персональные настройки, цели и параметры пользователя. + +``` +┌─────────────────────────────────────┐ +│ Профиль │ +├─────────────────────────────────────┤ +│ │ +│ ┌──────────┐ │ +│ │ [Аватар] │ │ +│ └──────────┘ │ +│ Алексей Иванов │ +│ │ +│ Мои параметры │ +│ ┌───────────────────────────────┐ │ +│ │ Рост 178 см │ │ +│ │ Вес 75 кг │ │ +│ │ Возраст 28 лет │ │ +│ │ Пол Мужской │ │ +│ │ Активность Средняя │ │ +│ └───────────────────────────────┘ │ +│ │ +│ Цель │ +│ ┌───────────────────────────────┐ │ +│ │ ● Поддержание веса │ │ +│ │ ○ Похудение │ │ +│ │ ○ Набор массы │ │ +│ └───────────────────────────────┘ │ +│ │ +│ Рекомендуемая норма: 2 100 ккал │ +│ Б: 120г · Ж: 70г · У: 260г │ +│ │ +│ Ограничения и предпочтения │ +│ ┌───────────────────────────────┐ │ +│ │ [ ] Вегетарианство │ │ +│ │ [ ] Без глютена │ │ +│ │ [ ] Без лактозы │ │ +│ │ [✓] Без орехов (аллергия) │ │ +│ │ [ ] Халяль │ │ +│ │ [ ] Кошерное │ │ +│ └───────────────────────────────┘ │ +│ │ +│ Любимые кухни │ +│ ┌───────────────────────────────┐ │ +│ │ [Русская✓] [Азиатская✓] │ │ +│ │ [Европ.] [Средизем.] [Кавк.] │ │ +│ └───────────────────────────────┘ │ +│ │ +│ ┌───────────────────────────────┐ │ +│ │ 📊 Статистика │ │ +│ ├───────────────────────────────┤ │ +│ │ 📖 Избранные рецепты │ │ +│ ├───────────────────────────────┤ │ +│ │ 📝 Мои отзывы │ │ +│ ├───────────────────────────────┤ │ +│ │ ⏱ Сроки хранения продуктов │ │ +│ ├───────────────────────────────┤ │ +│ │ ⚙️ Настройки приложения │ │ +│ ├───────────────────────────────┤ │ +│ │ ❓ Помощь │ │ +│ └───────────────────────────────┘ │ +│ │ +│ Версия 1.0.0 │ +│ │ +├─────────────────────────────────────┤ +│ [Главная] [Продукты] [Меню] [Рецепты] [Профиль] │ +└─────────────────────────────────────┘ +``` + +### Экран «Сроки хранения продуктов» + +``` +┌─────────────────────────────────────┐ +│ [←] Сроки хранения по умолчанию │ +├─────────────────────────────────────┤ +│ │ +│ Укажите, сколько дней после │ +│ покупки хранятся продукты │ +│ каждой категории │ +│ │ +│ ┌───────────────────────────────┐ │ +│ │ Молочные продукты [5] дн. │ │ +│ │ Мясо и птица [3] дн. │ │ +│ │ Рыба и морепродукты [2] дн. │ │ +│ │ Овощи и фрукты [7] дн. │ │ +│ │ Хлеб и выпечка [3] дн. │ │ +│ │ Яйца [14] дн. │ │ +│ │ Крупы и макароны [без срока] │ +│ │ Консервы [без срока] │ +│ │ Специи [без срока] │ +│ │ Замороженные [30] дн. │ │ +│ └───────────────────────────────┘ │ +│ │ +│ Значения будут подставляться │ +│ при добавлении новых продуктов. │ +│ Для каждого продукта срок можно │ +│ скорректировать индивидуально. │ +│ │ +└─────────────────────────────────────┘ +``` + +### Элементы и поведение + +- **Аватар и имя:** фото пользователя (тап — смена фото), имя под ним. +- **Мои параметры:** карточка с физическими данными. Тап по любому полю — редактирование. Используются для расчёта нормы калорий. +- **Цель:** radio-кнопки. Смена цели пересчитывает рекомендуемую калорийность. +- **Рекомендуемая норма:** автоматически рассчитанные калории и БЖУ на основе параметров и цели. Формула Миффлина-Сан Жеора. +- **Ограничения и предпочтения:** чекбоксы. Влияют на фильтрацию рецептов — рецепты с запрещёнными ингредиентами помечаются предупреждением или скрываются. +- **Любимые кухни:** chip-кнопки. Влияют на порядок и приоритет рекомендаций. +- **Сроки хранения продуктов:** экран с настройками периодов хранения по категориям. Каждая категория — редактируемое числовое поле. Значения используются как умолчания при добавлении новых продуктов. +- **Список ссылок:** переходы на экраны статистики, избранных рецептов, отзывов, настроек, помощи. +- **Настройки приложения (отдельный экран):** уведомления (типы и время), тема (светлая/тёмная/системная), единицы измерения, норма воды (стаканов в день), язык, экспорт данных, удаление аккаунта. + +--- + +## Общие паттерны UI + +### Навигация +- **Bottom Tab Bar** — 5 вкладок, всегда видна (кроме режима готовки, камеры и онбординга). +- **Stack navigation** внутри каждой вкладки — с кнопкой «Назад» в шапке. +- **Модальные окна (bottom sheets)** — для фильтров, выбора, подтверждений. + +### Жесты +- **Свайп влево** по элементам списков — действия (редактировать, удалить). +- **Long press** — drag-and-drop в меню. +- **Pull to refresh** — обновление данных на списках. + +### Обратная связь +- **Toast/snackbar** — при успешных действиях (продукт добавлен, блюдо записано в дневник). Для необратимых действий (удалить запись из дневника) — toast с кнопкой «Отменить» (5 секунд). +- **Скелетоны (shimmer)** — при загрузке контента. +- **Анимация AI-распознавания** — при обработке фото. Текст: «Распознаём...» с анимированным индикатором. Среднее время — 3–5 сек. + +### Пустые состояния +Каждый экран со списком данных имеет пустое состояние: +- **Иллюстрация** — лёгкий рисунок по теме экрана. +- **Текст** — что здесь будет отображаться и как начать. +- **CTA-кнопка** — основное действие для начала работы. + +### Состояния ошибок +- **Нет сети:** баннер вверху экрана «Нет подключения к интернету». Кэшированные данные (продукты, меню, дневник) доступны оффлайн. Действия, требующие сети (распознавание фото, загрузка рецептов), показывают модалку с предложением повторить позже. +- **Ошибка распознавания:** «Не удалось распознать. Попробуйте ещё раз» + «Ввести вручную» как fallback. +- **Ошибка сервера:** «Что-то пошло не так» + «Повторить». + +### Цветовая система +- **Акцентный цвет** — для CTA-кнопок и активных элементов. +- **Зелёный** — в пределах нормы, продукт в наличии, ингредиент есть. +- **Жёлтый/оранжевый** — предупреждение (скоро истечёт срок, калории приближаются к лимиту, есть замена ингредиента). +- **Красный** — превышение нормы, истёкший срок, отсутствующий ингредиент без замены. + +### Типографика +- **Заголовки** — жирные, крупные. +- **Тело текста** — стандартный размер, с достаточным межстрочным интервалом. +- **В режиме готовки** — увеличенный шрифт для комфортного чтения на расстоянии. + +### Уведомления +- **Срок годности:** «Молоко 2.5% — осталось 1 день. Используйте в рецепте?» (с ссылкой на рекомендации). Утром за 1 день и за 3 дня до истечения. +- **Таймер готовки:** звуковой сигнал + push «Таймер завершён: [название шага]». +- **Напоминание о приёме пищи:** «Время обеда! В меню: Куриная грудка с рисом» (если настроено). +- **Трекер воды:** вечернее напоминание «Вы выпили 5 из 8 стаканов воды сегодня» (если настроено). diff --git a/docs/Tech.md b/docs/Tech.md new file mode 100644 index 0000000..00235d9 --- /dev/null +++ b/docs/Tech.md @@ -0,0 +1,835 @@ +# FoodAI — Техническая архитектура + +## 1. Стек технологий + +| Компонент | Технология | Обоснование | +|-----------|-----------|-------------| +| Backend | Go | Высокая производительность, встроенная конкурентность (горутины для очередей), строгая типизация, простой деплой (один бинарник) | +| База данных | PostgreSQL | Надёжная реляционная БД, JSONB для гибких структур (нутриенты, шаги рецептов), полнотекстовый поиск, зрелая экосистема | +| Клиент | Flutter (Android, iOS, Web) | Единая кодовая база на три платформы, нативная производительность на мобильных, зрелая экосистема виджетов | +| Авторизация | Firebase Auth | Бесплатно до 50K MAU, email + Google + Apple из коробки, официальный Go SDK, отличная интеграция с Flutter | +| AI (vision) | Google Gemini 2.5 Flash | Лучшее соотношение цена/качество для распознавания еды (~$0.15/1M input tokens), бесплатный tier для разработки, прецедент CalCam | +| AI (текст) | Google Gemini 2.5 Flash-Lite | Самый дешёвый вариант для текстовых задач ($0.10/1M input tokens) — генерация рецептов, меню, замены ингредиентов | +| База рецептов | Spoonacular API | 365K+ рецептов с нутриентами, фото, ингредиентами, шагами. $29/мес на старте | +| Хранение файлов | S3-совместимое (MinIO / Cloud Storage) | Фото блюд от пользователей, фото рецептов | + +--- + +## 2. Архитектура системы + +``` +┌──────────────────────────────────────────────────────────┐ +│ Flutter Client │ +│ (Android / iOS / Web) │ +│ │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ +│ │ Firebase │ │ REST │ │ File │ │ +│ │ Auth SDK │ │ Client │ │ Upload │ │ +│ └─────┬────┘ └─────┬────┘ └─────┬────┘ │ +└────────┼─────────────┼─────────────┼─────────────────────┘ + │ │ │ + │ idToken │ JWT │ multipart + ▼ ▼ ▼ +┌──────────────────────────────────────────────────────────┐ +│ Go Backend │ +│ │ +│ ┌─────────────┐ ┌──────────────┐ ┌────────────────┐ │ +│ │ Auth │ │ API │ │ AI Queue │ │ +│ │ Middleware │ │ Handlers │ │ Manager │ │ +│ │ │ │ │ │ │ │ +│ │ Firebase │ │ /products │ │ Priority │ │ +│ │ Token │ │ /recipes │ │ Queues: │ │ +│ │ Verify │ │ /menu │ │ - paid (fast) │ │ +│ │ → JWT │ │ /diary │ │ - free (slow) │ │ +│ └──────┬──────┘ │ /ai │ │ │ │ +│ │ │ /shopping │ │ Rate Limiter │ │ +│ │ └──────┬───────┘ │ Budget Guard │ │ +│ │ │ └───────┬────────┘ │ +│ │ │ │ │ +│ ┌──────┴────────────────┴──────────────────┴────────┐ │ +│ │ Service Layer │ │ +│ │ │ │ +│ │ ┌──────────┐ ┌───────────┐ ┌──────────────────┐ │ │ +│ │ │ Product │ │ Recipe │ │ AI Service │ │ │ +│ │ │ Service │ │ Service │ │ (interface) │ │ │ +│ │ └────┬─────┘ └─────┬─────┘ └────┬─────────────┘ │ │ +│ └───────┼─────────────┼────────────┼────────────────┘ │ +│ │ │ │ │ +└──────────┼─────────────┼────────────┼────────────────────┘ + │ │ │ + ▼ │ ▼ +┌──────────────────┐ │ ┌──────────────────┐ +│ │ │ │ │ +│ PostgreSQL │ │ │ Gemini API │ +│ │ │ │ (Flash / │ +│ - users │ │ │ Flash-Lite) │ +│ - products │ │ │ │ +│ - recipes │ │ └──────────────────┘ +│ - menu_plans │ │ +│ - meal_diary │ ▼ +│ - reviews │ ┌──────────────────┐ +│ - ai_tasks │ │ │ +│ │ │ Spoonacular │ +└──────────────────┘ │ API │ + │ │ + └──────────────────┘ +``` + +--- + +## 3. Авторизация + +### Поток авторизации + +``` +Flutter Firebase Go Backend + │ │ │ + │ 1. signInWithGoogle() │ │ + │ ──────────────────────► │ │ + │ │ │ + │ 2. Firebase idToken │ │ + │ ◄────────────────────── │ │ + │ │ │ + │ 3. POST /auth/login │ │ + │ {firebase_token} │ │ + │ ─────────────────────────┼─────────────────► │ + │ │ │ + │ │ 4. VerifyIDToken() │ + │ │ ◄─────────────── │ + │ │ ───────────────► │ + │ │ (uid, email) │ + │ │ │ + │ │ 5. Upsert user │ + │ │ в PostgreSQL │ + │ │ │ + │ 6. {jwt, refresh_token, │ │ + │ user} │ │ + │ ◄────────────────────────┼────────────────── │ + │ │ │ + │ 7. Сохранить JWT │ │ + │ в Secure Storage │ │ +``` + +### Детали + +- **Firebase Auth** обрабатывает все провайдеры (email/password, Google, Apple) на стороне клиента. +- **Go backend** получает Firebase `idToken`, верифицирует его через Firebase Admin SDK (`firebase.google.com/go/v4/auth`), извлекает `uid`, `email`, `name`. +- **Backend выдаёт собственный JWT** (подписанный секретом сервера) с `user_id`, `role` (free/paid), `exp`. Это позволяет не зависеть от Firebase при каждом API-запросе. +- **Refresh token** хранится в БД, ротируется при каждом использовании. +- **Apple Sign-In** обязателен на iOS при наличии любого стороннего входа (политика App Store). Firebase Auth обрабатывает Apple прозрачно. +- Flutter-пакеты: `firebase_auth`, `google_sign_in`, `sign_in_with_apple`. + +--- + +## 4. AI-подсистема (ядро приложения) + +### Абстракция + +AI-провайдер скрыт за интерфейсами. Это позволяет заменить Gemini на OpenAI или специализированный API без изменения бизнес-логики. + +``` +┌─────────────────────────────────────────┐ +│ AI Service (interface) │ +│ │ +│ FoodRecognizer │ +│ ├── RecognizeReceipt(image) → []Item │ +│ ├── RecognizeProducts(image) → []Item │ +│ └── RecognizeDish(image) → DishInfo │ +│ │ +│ RecipeGenerator │ +│ ├── GenerateRecipes(req) → []Recipe │ +│ └── SuggestSubstitutions(req) → []Sub │ +│ │ +│ MenuPlanner │ +│ └── GenerateMenu(req) → MenuPlan │ +│ │ +│ NutritionEstimator │ +│ └── EstimateNutrition(dish) → KBJU │ +│ │ +└──────────────┬──────────────────────────┘ + │ + ┌────────┴────────┐ + ▼ ▼ +┌───────────┐ ┌───────────┐ +│ Gemini │ │ OpenAI │ +│ Adapter │ │ Adapter │ +│ (active) │ │ (резерв) │ +└───────────┘ └───────────┘ +``` + +### AI-задачи: детали + +#### 4.1. Распознавание чека + +- **Модель:** Gemini 2.5 Flash (vision) +- **Вход:** фото чека (JPEG/PNG) +- **Промпт:** системная инструкция + фото → structured JSON +- **Выход:** + +```json +{ + "items": [ + { + "name": "Молоко 2.5%", + "quantity": 1, + "unit": "л", + "category": "dairy", + "price": 49.0, + "confidence": 0.95 + } + ], + "unrecognized": [ + { "raw_text": "ТОВ АРТИК 1ШТ", "price": 89.0 } + ] +} +``` + +- **Стратегия промпта:** «Ты — OCR-система для чеков из продуктовых магазинов. Извлеки список продуктов. Для каждого определи название, количество, единицу измерения, категорию. Если не можешь распознать позицию — добавь в unrecognized с оригинальным текстом. Ответ строго в JSON.» +- **Fallback:** если confidence < 0.5 — элемент идёт в `unrecognized`. + +#### 4.2. Распознавание продуктов (фото) + +- **Модель:** Gemini 2.5 Flash (vision) +- **Вход:** фото продуктов (холодильник, стол) +- **Выход:** аналогичная структура (без цены), но с приблизительным весом/количеством +- **Нюанс:** поддержка нескольких фото — результаты объединяются на бэкенде (дедупликация по названию + суммирование количеств). + +#### 4.3. Распознавание блюда + +- **Модель:** Gemini 2.5 Flash (vision) +- **Вход:** фото готового блюда +- **Выход:** + +```json +{ + "dish_name": "Паста Карбонара", + "weight_grams": 450, + "calories": 580, + "protein": 24, + "fat": 28, + "carbs": 56, + "confidence": 0.85, + "similar_dishes": ["Паста с беконом", "Спагетти алла Грича"] +} +``` + +- **Нюанс:** `similar_dishes` используется для поиска похожих рецептов в нашей БД (по названию, fuzzy search). + +#### 4.4. Генерация рецептов / подбор меню + +- **Модель:** Gemini 2.5 Flash-Lite (текст) — дешевле, vision не нужен +- **Ключевой принцип:** AI НЕ генерирует рецепты с нуля. Вместо этого: + +``` +1. Backend выбирает из БД кандидатов-рецептов (по фильтрам: кухня, сложность, время, ингредиенты) +2. AI получает список кандидатов (ID + название + ингредиенты + калории) + контекст юзера +3. AI ранжирует, комбинирует в меню, предлагает замены +4. Backend возвращает полные рецепты по ID из БД +``` + +- **Промпт для подбора меню:** + +``` +Контекст пользователя: +- Цель: 2100 ккал/день +- Ограничения: без орехов +- Предпочтения: русская, азиатская кухня +- Продукты в наличии: [список с количествами и сроками] + +Доступные рецепты (ID, название, калории, основные ингредиенты): +[список из 50–100 кандидатов из БД] + +Задача: составь меню на 7 дней (завтрак, обед, ужин, перекус). +Приоритет: использовать продукты с истекающим сроком. +Ответ: JSON с recipe_id для каждого слота. +``` + +- **Выход:** массив `{ day, meal_type, recipe_id }` — бэкенд подтягивает полные данные рецептов из БД. + +Это гарантирует согласованность: AI не придумывает рецепты, а выбирает из проверенной базы. + +#### 4.5. AI-генерация персональных рецептов + +Отдельный кейс — когда в БД нет подходящего рецепта из имеющихся продуктов: + +- **Модель:** Gemini 2.5 Flash-Lite +- **Промпт:** «Из продуктов [список] предложи рецепт. Формат: JSON с названием, ингредиентами (с граммовками), шагами, калорийностью, БЖУ.» +- **Результат:** сохраняется в БД как `source = 'ai_generated'`, помечается в UI как «AI-рецепт». +- **Валидация нутриентов:** бэкенд пересчитывает калории/БЖУ по справочнику (USDA/Spoonacular nutrition data) и корректирует, если AI ошибся более чем на 20%. + +#### 4.6. Замена ингредиентов + +- **Модель:** Gemini 2.5 Flash-Lite +- **Вход:** ингредиент + доступные продукты +- **Выход:** `[{ "original": "пекорино", "substitute": "пармезан", "ratio": "1:1", "note": "Менее острый вкус" }]` +- **Кеширование:** результаты замен кешируются в БД (таблица `ingredient_substitutions`), чтобы не запрашивать AI повторно для одинаковых пар. + +--- + +## 5. Маппинг ингредиентов (связь AI ↔ БД рецептов) + +### Проблема + +Gemini оперирует свободным текстом ("куриное филе", "помидоры черри"), а Spoonacular — структурированными ID ингредиентов. Продукты пользователя — тоже свободный текст. Нужен слой, который связывает все три мира. + +### Решение: таблица `ingredient_mappings` + +Каноническая таблица ингредиентов с алиасами на разных языках и привязкой к Spoonacular ID. + +``` +┌───────────────────────────────────────────────────────┐ +│ ingredient_mappings │ +│───────────────────────────────────────────────────────│ +│ id UUID │ +│ canonical_name "chicken_breast" │ +│ spoonacular_id 1015062 │ +│ aliases (JSONB) ["куриное филе", "куриная грудка", │ +│ "филе курицы", "chicken breast", │ +│ "chicken fillet"] │ +│ category "meat" │ +│ default_unit "g" │ +│ calories_per_100g 165 │ +│ protein_per_100g 31 │ +│ fat_per_100g 3.6 │ +│ carbs_per_100g 0 │ +│ storage_days 3 │ +└───────────────────────────────────────────────────────┘ +``` + +### Как работает связка + +``` +Продукт юзера ingredient_mappings Рецепт из Spoonacular +────────────── ─────────────────── ───────────────────── + +"Куриное филе" │ Ингредиент рецепта: + │ │ spoonacular_id: 1015062 + │ Fuzzy search │ │ + └────по aliases ───► canonical_name: ◄─── по spoonacular_id + "chicken_breast" + │ + MATCH: оба + ссылаются на + одну сущность +``` + +### Потоки по сценариям + +#### Сценарий 1: Добавление продукта (чек/фото → запасы) + +``` +1. Gemini возвращает: "Куриное филе, 500г" +2. Backend: fuzzy search по ingredient_mappings.aliases + SELECT * FROM ingredient_mappings + WHERE aliases @> '"куриное филе"'::jsonb + OR similarity(canonical_name, 'куриное филе') > 0.3 +3. Найдено → product.mapping_id = ingredient_mappings.id + Автоподставляются: category, default_unit, storage_days, нутриенты +4. Не найдено → разовый запрос к Gemini: + "К какому каноническому ингредиенту относится 'филе индейки'? + Ответь JSON: { canonical_name, category, calories_per_100g, ... }" + Результат сохраняется в ingredient_mappings (новая строка) + Следующий юзер с таким же продуктом — AI не нужен +``` + +#### Сценарий 2: Проверка "есть ли ингредиент рецепта в запасах" + +``` +1. Рецепт "Паста Карбонара" имеет ингредиент: spoonacular_id = 1015062 +2. ingredient_mappings: spoonacular_id 1015062 → canonical_name "chicken_breast" +3. products: mapping_id → ingredient_mappings.id WHERE canonical_name = "chicken_breast" +4. Найдено в products → отметка ✅ (есть в запасах) + Не найдено → отметка ❌ (нет) или 🔄 (есть замена) +``` + +#### Сценарий 3: Поиск рецептов "из моих продуктов" + +``` +1. Продукты юзера → через mapping_id → набор canonical_names + ["chicken_breast", "rice_white", "carrot", "onion"] + +2. SQL-запрос по рецептам: + SELECT r.*, + (SELECT count(*) FROM jsonb_array_elements(r.ingredients) i + WHERE i->>'mapping_id' IN (выбранные mapping_id)) as matched, + jsonb_array_length(r.ingredients) as total + FROM recipes r + ORDER BY matched::float / total DESC + +3. Если в локальной БД мало результатов — дозапрос Spoonacular: + GET /recipes/findByIngredients?ingredients=chicken,rice,carrot,onion + Новые рецепты сохраняются в нашу БД + +4. Gemini НЕ участвует — это чистый SQL + Spoonacular +``` + +#### Сценарий 4: Распознавание блюда (фото → калории) + +``` +1. Gemini определяет: "Паста Карбонара" +2. Backend: full-text search по recipes.title + SELECT * FROM recipes + WHERE to_tsvector('russian', title) @@ plainto_tsquery('russian', 'Паста Карбонара') + ORDER BY review_count DESC LIMIT 5 +3. Найдено → привязка к рецепту (точные нутриенты из БД) + Не найдено → используются нутриенты от Gemini (помечены "≈ приблизительно") +``` + +#### Сценарий 5: Генерация меню + +``` +1. Backend отбирает кандидатов из БД (SQL: фильтры + наличие ингредиентов) +2. Формирует промпт с recipe_id: + "ID:42 Борщ (550ккал, ингр: свёкла✅ картофель✅ говядина✅ лук✅) + ID:87 Том Ям (320ккал, ингр: креветки❌ лемонграсс❌ кокос.молоко❌)" +3. Gemini ранжирует и возвращает recipe_id +4. Backend подгружает полные данные по ID + +→ Gemini работает ТОЛЬКО с ID из нашей БД +→ Никакой рассинхронизации +``` + +### Наполнение таблицы маппингов + +| Этап | Источник | Объём | +|------|----------|-------| +| Начальный импорт | Spoonacular Ingredient API | ~1 000 базовых ингредиентов | +| Ручная локализация | Перевод топ-200 ингредиентов | Aliases на русском | +| Batch-перевод | Gemini Flash-Lite (оффлайн) | Остальные aliases | +| Рантайм-дополнение | Gemini (при нераспознанном продукте) | По мере роста юзеров | +| Пользовательская обратная связь | Юзер корректирует продукт на экране редактирования | Новые aliases | + +Со временем маппинг растёт, и AI для распознавания ингредиентов вызывается всё реже. + +### Сводная таблица: где Gemini, а где нет + +| Задача | Gemini? | Как стыкуется с БД | +|--------|---------|---------------------| +| OCR чека → продукты | Да (vision) | Fuzzy match по `ingredient_mappings.aliases` | +| Фото продуктов → список | Да (vision) | То же | +| Фото блюда → калории | Да (vision) | Full-text search по `recipes.title` | +| Поиск рецептов из продуктов | **Нет** | SQL по `mapping_id` + Spoonacular `findByIngredients` | +| Ранжировка/подбор меню | Да (текст) | Получает `recipe_id` из БД, возвращает `recipe_id` | +| Замена ингредиентов | Да (текст) | Кеш в `ingredient_substitutions` | +| Нераспознанный ингредиент | Да (текст, разово) | Результат сохраняется в `ingredient_mappings` | + +--- + +## 6. Система очередей AI-запросов + +### Проблема + +Gemini API имеет ограничения по RPM (requests per minute) и стоит денег. Нужно: +1. Не допустить перерасход бюджета +2. Дать приоритет платным пользователям +3. Не блокировать систему при пиковых нагрузках + +### Архитектура + +``` + Входящий AI-запрос + │ + ▼ + ┌─────────────────────┐ + │ Rate Limiter │ + │ (per-user) │ + │ │ + │ Free: 20 req/час │ + │ Paid: 100 req/час │ + └──────────┬──────────┘ + │ + ┌──────┴──────┐ + │ │ + Paid user Free user + │ │ + ▼ ▼ + ┌─────────────┐ ┌─────────────┐ + │ Paid Queue │ │ Free Queue │ + │ │ │ │ + │ N воркеров │ │ 1 воркер │ + │ (быстро) │ │ (медленно) │ + └──────┬──────┘ └──────┬──────┘ + │ │ + └───────┬───────┘ + │ + ▼ + ┌─────────────────────┐ + │ Budget Guard │ + │ │ + │ Daily limit: $X │ + │ Current: $Y │ + │ │ + │ Y >= X? → REJECT │ + │ Y >= 0.8X? → WARN │ + └──────────┬──────────┘ + │ + ▼ + ┌─────────────────────┐ + │ Gemini API Call │ + │ │ + │ + Track cost │ + │ + Log to ai_tasks │ + └─────────────────────┘ +``` + +### Компоненты + +#### Rate Limiter (per-user) +- Алгоритм: token bucket (горутина + каналы в Go). +- Лимиты хранятся в конфигурации, разделены по тарифу (free/paid). +- При превышении: HTTP 429 + заголовок `Retry-After`. +- Клиент показывает: «Слишком много запросов. Попробуйте через N секунд.» + +#### Priority Queues +- **Paid Queue:** N воркеров (горутин), обрабатывающих запросы параллельно. N зависит от лимита RPM Gemini API (например, при лимите 60 RPM и двух очередях — 50 RPM на paid, 10 RPM на free). +- **Free Queue:** 1 воркер, обрабатывает запросы последовательно. Пользователи free-тарифа ждут дольше, но результат получают. +- Реализация: `chan AITask` в Go с горутинами-воркерами. Без внешних брокеров сообщений на данном этапе. +- При масштабировании: миграция на Redis Streams или NATS. + +#### Budget Guard +- **Daily budget cap:** максимальная сумма затрат на Gemini API в день (например, $50). +- **Учёт затрат:** каждый запрос логируется с оценочной стоимостью (input_tokens × price + output_tokens × price). Gemini API возвращает `usage_metadata` с точными токенами. +- **Пороги:** + - 80% бюджета: предупреждение в логи, приоритет только paid-очереди. + - 100% бюджета: free-очередь останавливается, paid продолжает (из резерва). + - 120% бюджета (абсолютный лимит): все AI-запросы отклоняются. +- **Graceful degradation:** при отклонении запроса клиент показывает: «AI-распознавание временно недоступно. Вы можете добавить продукты вручную.» + +#### Таблица `ai_tasks` (лог) + +| Поле | Тип | Описание | +|------|-----|----------| +| id | UUID | | +| user_id | UUID | FK → users | +| task_type | ENUM | receipt_ocr, product_recognition, dish_recognition, recipe_generation, menu_planning, substitution | +| status | ENUM | queued, processing, completed, failed, rejected | +| priority | ENUM | free, paid | +| input_tokens | INT | Токены на вход (от API) | +| output_tokens | INT | Токены на выход (от API) | +| estimated_cost | DECIMAL | Оценочная стоимость в $ | +| queue_time_ms | INT | Время ожидания в очереди | +| process_time_ms | INT | Время обработки | +| created_at | TIMESTAMP | | +| completed_at | TIMESTAMP | | + +Используется для: аналитики затрат, мониторинга, оптимизации промптов. + +--- + +## 7. Рецепты и контент + +### Источники рецептов + +``` +┌────────────────────────────────────────────────────┐ +│ Recipe Service │ +│ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────┐ │ +│ │ Spoonacular │ │ AI-generated │ │ User │ │ +│ │ Import │ │ Recipes │ │ Recipes │ │ +│ │ │ │ │ │ │ │ +│ │ source: │ │ source: │ │ source: │ │ +│ │ 'spoonacular'│ │ 'ai' │ │ 'user' │ │ +│ └──────┬───────┘ └──────┬───────┘ └────┬─────┘ │ +│ │ │ │ │ +│ └────────┬────────┘───────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌────────────────┐ │ +│ │ Единая таблица │ │ +│ │ recipes в БД │ │ +│ └────────────────┘ │ +└────────────────────────────────────────────────────┘ +``` + +### Импорт из Spoonacular + +- **Начальный импорт:** 5 000–10 000 самых популярных рецептов (по рейтингу). +- **Данные:** название, описание, ингредиенты с граммовками, шаги приготовления, калории, БЖУ, время, сложность, кухня, фото, теги. +- **Хранение:** в нашей БД (PostgreSQL). Не зависим от Spoonacular API при показе рецептов пользователю. +- **Синхронизация:** фоновый джоб раз в неделю — обновление данных, добавление новых рецептов из популярных. +- **Spoonacular API** используется также для: расширенного поиска (когда в локальной БД нет подходящего), справочника нутриентов, данных по ингредиентам. +- **Локализация:** рецепты из Spoonacular на английском. Перевод — через Gemini Flash-Lite (batch, оффлайн). Переведённые рецепты кешируются в БД. + +### Согласованность AI и рецептов + +**Критическое правило:** AI при подборе меню работает ТОЛЬКО с рецептами из нашей БД. + +Поток: +1. Backend выбирает кандидатов из БД по фильтрам (SQL-запрос: кухня, сложность, калории, ингредиенты). +2. Backend формирует промпт с ID и метаданными кандидатов. +3. AI ранжирует и компонует меню, возвращая `recipe_id`. +4. Backend подгружает полные данные рецептов по ID. + +Это гарантирует: точные нутриенты, наличие фото, корректные шаги, возможность оставить отзыв. + +**Исключение:** AI-генерация нового рецепта «из того, что есть» — когда в БД нет подходящего. Такой рецепт сохраняется в БД как `source = 'ai'` и доступен другим пользователям после модерации (по рейтингу). + +--- + +## 8. Доменные сущности (схема БД) + +``` +┌──────────────┐ ┌──────────────────┐ +│ users │ │ products │ +│──────────────│ │──────────────────│ +│ id │◄──┐ │ id │ +│ firebase_uid │ │ │ user_id (FK) │──► users +│ email │ │ │ mapping_id (FK) │──► ingredient_mappings +│ name │ │ │ name │ +│ avatar_url │ │ │ quantity │ +│ height_cm │ │ │ unit │ +│ weight_kg │ │ │ category │ +│ age │ │ │ storage_days │ +│ gender │ │ │ added_at │ +│ activity │ │ │ expires_at │ +│ goal │ │ │ (computed) │ +│ │ │ └──────────────────┘ +│ plan (free/ │ │ +│ paid) │ │ ┌──────────────────┐ +│ preferences │ │ │ recipes │ +│ (JSONB) │ │ │──────────────────│ +│ created_at │ │ │ id │ +└──────────────┘ │ │ source │ + │ │ spoonacular_id │ + │ │ title │ + │ │ description │ + │ │ cuisine │ + │ │ difficulty │ + │ │ prep_time_min │ + │ │ calories │ + │ │ protein │ + │ │ fat │ + │ │ carbs │ + │ │ servings │ + │ │ image_url │ + │ │ ingredients │ + │ │ (JSONB) │ + │ │ steps (JSONB) │ + │ │ tags (JSONB) │ + │ │ avg_rating │ + │ │ review_count │ + │ │ created_by │──► users (NULL для spoonacular) + │ └──────────────────┘ + │ +┌──────────────────┤ ┌──────────────────┐ +│ menu_plans │ │ menu_items │ +│──────────────────│ │──────────────────│ +│ id │ │ id │ +│ user_id (FK) ───┘ │ menu_plan_id(FK) │──► menu_plans +│ week_start │ │ day_of_week │ +│ created_at │ │ meal_type │ +│ template_name │ │ recipe_id (FK) │──► recipes +│ (NULL если не │ │ servings │ +│ шаблон) │ └──────────────────┘ +└──────────────────┘ + ┌──────────────────┐ + │ meal_diary │ + │──────────────────│ + │ id │ + │ user_id (FK) │──► users + │ date │ + │ meal_type │ + │ recipe_id (FK) │──► recipes (NULL для ручного ввода) + │ name │ + │ portions │ + │ calories │ + │ protein │ + │ fat │ + │ carbs │ + │ source │ + │ (menu/photo/ │ + │ manual/recipe) │ + │ created_at │ + └──────────────────┘ + +┌──────────────────┐ ┌──────────────────┐ +│ reviews │ │ shopping_lists │ +│──────────────────│ │──────────────────│ +│ id │ │ id │ +│ user_id (FK) │──►│ user_id (FK) │──► users +│ recipe_id (FK) │──►│ menu_plan_id(FK) │──► menu_plans +│ rating (1-5) │ │ items (JSONB) │ +│ text │ │ created_at │ +│ photo_url │ └──────────────────┘ +│ created_at │ +└──────────────────┘ ┌──────────────────┐ + │ water_tracker │ + │──────────────────│ + │ id │ + │ user_id (FK) │──► users + │ date │ + │ glasses │ + └──────────────────┘ + +┌──────────────────┐ ┌──────────────────────────┐ +│ ai_tasks │ │ ingredient_substitutions │ +│──────────────────│ │──────────────────────────│ +│ (см. раздел 6) │ │ id │ +└──────────────────┘ │ original_mapping_id (FK) │──► ingredient_mappings + │ substitute_mapping_id(FK)│──► ingredient_mappings + │ ratio │ + │ note │ + │ created_at │ + └──────────────────────────┘ + +┌────────────────────────────┐ +│ ingredient_mappings │ +│────────────────────────────│ +│ id │ +│ canonical_name │ +│ spoonacular_id (UNIQUE) │ +│ aliases (JSONB) │ +│ category │ +│ default_unit │ +│ calories_per_100g │ +│ protein_per_100g │ +│ fat_per_100g │ +│ carbs_per_100g │ +│ storage_days │ +│ created_at │ +└────────────────────────────┘ +``` + +### Ключевые решения по схеме + +- **`products.mapping_id`** — FK на `ingredient_mappings`. Связывает продукт пользователя с каноническим ингредиентом. Через эту связь определяется наличие ингредиентов рецепта в запасах. Может быть NULL (если продукт не удалось сопоставить). +- **`ingredient_mappings`** — каноническая таблица ингредиентов. `aliases` (JSONB) содержит все варианты написания на разных языках. `spoonacular_id` связывает с ингредиентами рецептов из Spoonacular. Нутриенты на 100г используются для пересчёта калорийности AI-генерированных рецептов. `storage_days` — дефолтный срок хранения для категории. +- **`ingredient_substitutions`** — кеш замен ингредиентов. Ссылается на `ingredient_mappings` по обоим ингредиентам (оригинал и замена). Один раз определённая AI-замена переиспользуется для всех пользователей. +- **`products.storage_days`** — период хранения после покупки (не фиксированная дата). `expires_at` вычисляется как `added_at + storage_days`. При отображении: «осталось X дней». Дефолт берётся из `ingredient_mappings.storage_days` при привязке. +- **`products.unit`** — ENUM: g, kg, ml, l, pcs, bunch, pack. Фронтенд отображает локализованные названия. Дефолт берётся из `ingredient_mappings.default_unit`. +- **`recipes.ingredients`** — JSONB массив: `[{ "name": "...", "amount": 200, "unit": "g", "optional": false }]`. JSONB позволяет гибко хранить без нормализации, при этом поддерживая поиск (`@>` оператор). +- **`recipes.steps`** — JSONB массив: `[{ "order": 1, "title": "...", "description": "...", "image_url": "...", "timer_seconds": null }]`. `timer_seconds` не null → на шаге отображается таймер. +- **`recipes.source`** — ENUM: spoonacular, ai, user. Определяет происхождение рецепта. +- **`meal_diary.source`** — откуда записано: из меню, через фото, вручную, из рецепта. +- **`shopping_lists.items`** — JSONB: `[{ "name": "...", "amount": 1, "unit": "l", "category": "dairy", "checked": false, "manual": false }]`. + +--- + +## 9. API-дизайн (ключевые эндпоинты) + +### Авторизация + +| Метод | Путь | Описание | +|-------|------|----------| +| POST | `/auth/login` | Firebase token → JWT | +| POST | `/auth/refresh` | Обновить JWT | +| POST | `/auth/logout` | Инвалидировать refresh token | + +### Продукты + +| Метод | Путь | Описание | +|-------|------|----------| +| GET | `/products` | Список продуктов (фильтры: category, expiring) | +| POST | `/products` | Добавить продукт вручную | +| POST | `/products/batch` | Массовое добавление (после распознавания) | +| PUT | `/products/{id}` | Обновить (вес, количество, срок) | +| PATCH | `/products/{id}/consume` | Частичное использование | +| DELETE | `/products/{id}` | Удалить | +| DELETE | `/products` | Очистить все (с подтверждением в query param) | +| GET | `/products/storage-defaults` | Дефолтные сроки хранения по категориям | +| PUT | `/products/storage-defaults` | Обновить дефолтные сроки | + +### AI-операции + +| Метод | Путь | Описание | +|-------|------|----------| +| POST | `/ai/recognize-receipt` | Фото чека → список продуктов | +| POST | `/ai/recognize-products` | Фото продуктов → список | +| POST | `/ai/recognize-dish` | Фото блюда → калории, БЖУ | +| POST | `/ai/suggest-recipes` | Подбор рецептов из продуктов | +| POST | `/ai/generate-menu` | Генерация меню на период | +| POST | `/ai/substitute` | Замена ингредиента | +| GET | `/ai/tasks/{id}` | Статус AI-задачи (для polling) | + +Все `/ai/*` эндпоинты возвращают `{ task_id }` (HTTP 202 Accepted). Клиент получает результат через polling `/ai/tasks/{id}` или через WebSocket (будущее улучшение). Это позволяет обрабатывать запросы асинхронно через очереди. + +### Рецепты + +| Метод | Путь | Описание | +|-------|------|----------| +| GET | `/recipes` | Каталог (фильтры, поиск, пагинация) | +| GET | `/recipes/{id}` | Карточка рецепта | +| GET | `/recipes/recommended` | Персональные рекомендации | +| GET | `/recipes/recent` | Недавно приготовленные | +| POST | `/recipes` | Создать пользовательский рецепт | +| POST | `/recipes/{id}/favorite` | Добавить в избранное | +| DELETE | `/recipes/{id}/favorite` | Убрать из избранного | + +### Отзывы + +| Метод | Путь | Описание | +|-------|------|----------| +| GET | `/recipes/{id}/reviews` | Отзывы к рецепту | +| POST | `/recipes/{id}/reviews` | Написать отзыв | + +### Меню + +| Метод | Путь | Описание | +|-------|------|----------| +| GET | `/menu?week=2026-W08` | Меню на неделю | +| PUT | `/menu/items/{id}` | Обновить слот меню | +| POST | `/menu/items` | Добавить блюдо в слот | +| DELETE | `/menu/items/{id}` | Убрать блюдо | +| POST | `/menu/templates` | Сохранить как шаблон | +| GET | `/menu/templates` | Список шаблонов | +| POST | `/menu/from-template/{id}` | Применить шаблон | + +### Дневник питания + +| Метод | Путь | Описание | +|-------|------|----------| +| GET | `/diary?date=2026-02-15` | Записи за день | +| POST | `/diary` | Добавить запись | +| PUT | `/diary/{id}` | Изменить (порция, и т.д.) | +| DELETE | `/diary/{id}` | Удалить запись | + +### Список покупок + +| Метод | Путь | Описание | +|-------|------|----------| +| GET | `/shopping-list` | Текущий список | +| POST | `/shopping-list/generate` | Сгенерировать из меню | +| PUT | `/shopping-list/items/{index}` | Обновить позицию | +| PATCH | `/shopping-list/items/{index}/check` | Отметить купленным | +| POST | `/shopping-list/items` | Добавить вручную | + +### Статистика + +| Метод | Путь | Описание | +|-------|------|----------| +| GET | `/stats?period=week` | Статистика за период | +| GET | `/stats/water?date=2026-02-15` | Трекер воды | +| PUT | `/stats/water` | Обновить стаканы | + +### Профиль + +| Метод | Путь | Описание | +|-------|------|----------| +| GET | `/profile` | Данные профиля | +| PUT | `/profile` | Обновить | +| PUT | `/profile/preferences` | Обновить предпочтения кухонь | +| PUT | `/profile/restrictions` | Обновить ограничения | + +--- + +## 10. Оценка затрат + +### Стоимость по стадиям (в месяц) + +| Компонент | 1K MAU | 10K MAU | 50K MAU | +|-----------|--------|---------|---------| +| Firebase Auth | $0 | $0 | $0 (лимит 50K) | +| Gemini API | $50–150 | $500–1 500 | $2 500–7 500 | +| Spoonacular | $29 | $149 | $149 | +| Хостинг (Cloud Run / Fly.io) | $10–20 | $50–100 | $200–500 | +| PostgreSQL (managed) | $15 | $50 | $150–300 | +| S3 (фото) | $5 | $20 | $50–100 | +| **Итого** | **$110–220** | **$770–1 820** | **$3 050–8 550** | + +### Оптимизация затрат на AI + +- **Кеширование результатов:** замены ингредиентов, распознавание типовых блюд. +- **Batch-запросы:** при генерации меню на неделю — один запрос вместо семи. +- **Снижение detail для фото:** Gemini поддерживает `low` / `high` detail. Для чеков `high`, для фото продуктов `low` достаточно. +- **Мониторинг:** дашборд затрат по `ai_tasks` — какие задачи дорогие, где можно оптимизировать промпты. diff --git a/docs/plans/Iteration_0.md b/docs/plans/Iteration_0.md new file mode 100644 index 0000000..d8e8162 --- /dev/null +++ b/docs/plans/Iteration_0.md @@ -0,0 +1,1884 @@ +# Итерация 0: Фундамент + +**Цель:** развернуть скелет проекта, базу данных, авторизацию и каркас мобильного приложения. После итерации можно зарегистрироваться, войти и увидеть пустые экраны. + +**Зависимости:** нет. + +**Ориентир:** [Summary.md](./Summary.md) → stories 0.1–0.10 + +--- + +## Структура задач + +``` +0.1 Go-проект + ├── 0.1.1 Инициализация модуля и структуры каталогов + ├── 0.1.2 Конфигурация (envconfig) + ├── 0.1.3 Логирование (slog) + └── 0.1.4 Graceful shutdown + +0.2 PostgreSQL + миграции + ├── 0.2.1 Подключение к PostgreSQL (pgxpool) + ├── 0.2.2 Система миграций (goose) + └── 0.2.3 Начальная миграция: таблица users + +0.3 HTTP-сервер + ├── 0.3.1 chi-роутер + базовый сервер + ├── 0.3.2 Middleware: CORS, request ID, logging, recovery + └── 0.3.3 Healthcheck endpoint + +0.4 Firebase Auth + ├── 0.4.1 Firebase Admin SDK — верификация idToken + ├── 0.4.2 JWT-генерация (собственный токен) + ├── 0.4.3 Auth middleware (проверка JWT на защищённых роутах) + ├── 0.4.4 POST /auth/login + ├── 0.4.5 POST /auth/refresh + └── 0.4.6 POST /auth/logout + +0.5 Users сервис + ├── 0.5.1 Миграция: полная таблица users + ├── 0.5.2 Repository (CRUD) + ├── 0.5.3 Service layer + ├── 0.5.4 GET /profile, PUT /profile + +0.6 Docker Compose + Makefile + +0.7 Flutter-проект + ├── 0.7.1 Инициализация и структура каталогов + ├── 0.7.2 Подключение пакетов + └── 0.7.3 Тема и дизайн-токены + +0.8 Firebase Auth (Flutter) + ├── 0.8.1 Firebase-проект + конфигурация + ├── 0.8.2 Экран входа (email + Google + Apple) + ├── 0.8.3 Экран регистрации + └── 0.8.4 Хранение JWT в secure storage + +0.9 Навигация и каркас + ├── 0.9.1 go_router + Bottom Tab Bar + ├── 0.9.2 5 экранов-заглушек + └── 0.9.3 Auth guard (редирект неавторизованных) + +0.10 API-клиент (Flutter) + ├── 0.10.1 Dio-клиент + interceptors + ├── 0.10.2 Auth interceptor (JWT + refresh) + └── 0.10.3 Модель User + сериализация +``` + +--- + +## 0.1 Инициализация Go-проекта + +### 0.1.1 Структура каталогов + +``` +backend/ +├── cmd/ +│ └── server/ +│ └── main.go # Точка входа +├── internal/ +│ ├── config/ +│ │ └── config.go # Структура конфигурации +│ ├── server/ +│ │ └── server.go # HTTP-сервер, роутер, graceful shutdown +│ ├── middleware/ +│ │ ├── cors.go +│ │ ├── logging.go +│ │ ├── recovery.go +│ │ ├── request_id.go +│ │ └── auth.go # JWT-проверка +│ ├── auth/ +│ │ ├── handler.go # HTTP-хэндлеры /auth/* +│ │ ├── service.go # Бизнес-логика авторизации +│ │ ├── firebase.go # Firebase Admin SDK обёртка +│ │ └── jwt.go # Генерация/валидация JWT +│ ├── user/ +│ │ ├── handler.go # HTTP-хэндлеры /profile +│ │ ├── service.go # Бизнес-логика +│ │ ├── repository.go # PostgreSQL-запросы +│ │ └── model.go # Доменная модель User +│ └── database/ +│ └── postgres.go # Подключение pgxpool +├── migrations/ +│ └── 001_create_users.sql # Первая миграция +├── docker-compose.yml +├── Makefile +├── .env.example +└── go.mod +``` + +### Принципы + +- **Модуль:** `github.com//food_ai` (или внутренний путь) +- **Зависимости** подключаются по мере необходимости, не заранее +- Каждый домен (`auth`, `user`) — отдельный пакет с handler → service → repository +- Нет ORM — чистый pgx с SQL-запросами +- Нет глобальных переменных — всё через DI (конструкторы) + +### 0.1.2 Конфигурация + +```go +// internal/config/config.go + +type Config struct { + Port int `envconfig:"PORT" default:"8080"` + DatabaseURL string `envconfig:"DATABASE_URL" required:"true"` + + // Firebase + FirebaseCredentialsFile string `envconfig:"FIREBASE_CREDENTIALS_FILE" required:"true"` + + // JWT + JWTSecret string `envconfig:"JWT_SECRET" required:"true"` + JWTAccessDuration time.Duration `envconfig:"JWT_ACCESS_DURATION" default:"15m"` + JWTRefreshDuration time.Duration `envconfig:"JWT_REFRESH_DURATION" default:"720h"` // 30 дней + + // CORS + AllowedOrigins []string `envconfig:"ALLOWED_ORIGINS" default:"http://localhost:3000"` +} +``` + +- Пакет: `github.com/kelseyhightower/envconfig` +- `.env.example` с описанием всех переменных +- Никаких YAML/JSON-конфигов — только переменные окружения + +### 0.1.3 Логирование + +```go +// cmd/server/main.go — инициализация + +logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ + Level: slog.LevelInfo, +})) +slog.SetDefault(logger) +``` + +- Используем `log/slog` из стандартной библиотеки (Go 1.21+) +- JSON-формат для структурированного вывода +- Логгер передаётся через контекст или как зависимость в сервисы +- В middleware: логируем method, path, status, duration, request_id + +### 0.1.4 Graceful shutdown + +```go +// cmd/server/main.go + +ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) +defer stop() + +// Запуск сервера +go func() { + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + slog.Error("server error", "err", err) + } +}() + +<-ctx.Done() +slog.Info("shutting down...") + +shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) +defer cancel() + +srv.Shutdown(shutdownCtx) +pool.Close() // pgxpool +``` + +--- + +## 0.2 PostgreSQL + миграции + +### 0.2.1 Подключение к PostgreSQL + +```go +// internal/database/postgres.go + +func NewPool(ctx context.Context, databaseURL string) (*pgxpool.Pool, error) { + config, err := pgxpool.ParseConfig(databaseURL) + if err != nil { + return nil, fmt.Errorf("parse config: %w", err) + } + + config.MaxConns = 20 + config.MinConns = 5 + config.MaxConnLifetime = 30 * time.Minute + config.MaxConnIdleTime = 5 * time.Minute + + pool, err := pgxpool.NewWithConfig(ctx, config) + if err != nil { + return nil, fmt.Errorf("create pool: %w", err) + } + + if err := pool.Ping(ctx); err != nil { + return nil, fmt.Errorf("ping: %w", err) + } + + return pool, nil +} +``` + +- Пакет: `github.com/jackc/pgx/v5/pgxpool` +- Пул соединений, не единичное подключение +- MaxConns = 20 — достаточно для начала, настраивается через конфиг + +### 0.2.2 Система миграций + +- Пакет: `github.com/pressly/goose/v3` +- Миграции в `migrations/` — `.sql` файлы +- Команды в Makefile: + +```makefile +migrate-up: + goose -dir migrations postgres "$(DATABASE_URL)" up + +migrate-down: + goose -dir migrations postgres "$(DATABASE_URL)" down + +migrate-create: + goose -dir migrations create $(name) sql +``` + +- Миграции запускаются вручную через `make migrate-up`, НЕ при старте приложения +- Это даёт контроль над моментом применения миграций + +### 0.2.3 Начальная миграция: таблица users + +```sql +-- migrations/001_create_users.sql + +-- +goose Up +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +CREATE TYPE user_plan AS ENUM ('free', 'paid'); +CREATE TYPE user_gender AS ENUM ('male', 'female'); +CREATE TYPE user_goal AS ENUM ('lose', 'maintain', 'gain'); +CREATE TYPE activity_level AS ENUM ('low', 'moderate', 'high'); + +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + firebase_uid VARCHAR(128) NOT NULL UNIQUE, + email VARCHAR(255) NOT NULL, + name VARCHAR(255) NOT NULL DEFAULT '', + avatar_url TEXT, + + -- Параметры тела + height_cm SMALLINT, + weight_kg DECIMAL(5,2), + age SMALLINT, + gender user_gender, + activity activity_level, + + -- Цель и рассчитанная норма + goal user_goal, + daily_calories SMALLINT, -- Рассчитывается по формуле Миффлина-Сан Жеора + + -- Тариф + plan user_plan NOT NULL DEFAULT 'free', + + -- Предпочтения (JSONB для гибкости) + preferences JSONB NOT NULL DEFAULT '{}'::jsonb, + -- Пример: { + -- "cuisines": ["russian", "asian"], + -- "restrictions": ["nuts"], + -- "storage_defaults": { "dairy": 5, "meat": 3, "vegetables": 7 }, + -- "water_goal": 8, + -- "notifications": { "expiring": true, "meals": true, "water": true } + -- } + + -- Refresh token + refresh_token TEXT, + token_expires_at TIMESTAMPTZ, + + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_users_firebase_uid ON users (firebase_uid); +CREATE INDEX idx_users_email ON users (email); + +-- +goose Down +DROP TABLE IF EXISTS users; +DROP TYPE IF EXISTS activity_level; +DROP TYPE IF EXISTS user_goal; +DROP TYPE IF EXISTS user_gender; +DROP TYPE IF EXISTS user_plan; +``` + +### Формула калорий (Mifflin-St Jeor) + +При заполнении профиля backend рассчитывает `daily_calories`: + +``` +Мужчины: BMR = 10 × вес(кг) + 6.25 × рост(см) − 5 × возраст − 161 + 166 +Женщины: BMR = 10 × вес(кг) + 6.25 × рост(см) − 5 × возраст − 161 + +Коэффициент активности: + low: 1.375 + moderate: 1.55 + high: 1.725 + +TDEE = BMR × коэффициент + +Цель: + lose: TDEE − 500 + maintain: TDEE + gain: TDEE + 300 +``` + +--- + +## 0.3 HTTP-сервер + +### 0.3.1 chi-роутер + +```go +// internal/server/server.go + +func NewRouter( + authHandler *auth.Handler, + userHandler *user.Handler, + authMiddleware func(http.Handler) http.Handler, +) *chi.Mux { + r := chi.NewRouter() + + // Global middleware + r.Use(middleware.RequestID) + r.Use(middleware.Logging) + r.Use(middleware.Recovery) + r.Use(middleware.CORS(allowedOrigins)) + + // Public + r.Get("/health", healthCheck) + r.Route("/auth", func(r chi.Router) { + r.Post("/login", authHandler.Login) + r.Post("/refresh", authHandler.Refresh) + r.Post("/logout", authHandler.Logout) + }) + + // Protected + r.Group(func(r chi.Router) { + r.Use(authMiddleware) + r.Get("/profile", userHandler.Get) + r.Put("/profile", userHandler.Update) + }) + + return r +} +``` + +- Пакет: `github.com/go-chi/chi/v5` +- Группировка роутов: public (auth, health) и protected (всё остальное) +- chi выбран за: совместимость с `net/http`, middleware-цепочки, вложенные роутеры + +### 0.3.2 Middleware + +#### Request ID + +```go +func RequestID(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + id := r.Header.Get("X-Request-ID") + if id == "" { + id = uuid.NewString() + } + ctx := context.WithValue(r.Context(), requestIDKey, id) + w.Header().Set("X-Request-ID", id) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} +``` + +#### Logging + +```go +func Logging(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + ww := &responseWriter{ResponseWriter: w, statusCode: 200} + + next.ServeHTTP(ww, r) + + slog.Info("request", + "method", r.Method, + "path", r.URL.Path, + "status", ww.statusCode, + "duration_ms", time.Since(start).Milliseconds(), + "request_id", RequestIDFromCtx(r.Context()), + ) + }) +} +``` + +#### CORS + +```go +func CORS(allowedOrigins []string) func(http.Handler) http.Handler { + return cors.Handler(cors.Options{ + AllowedOrigins: allowedOrigins, + AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}, + AllowedHeaders: []string{"Authorization", "Content-Type", "X-Request-ID"}, + ExposedHeaders: []string{"X-Request-ID"}, + AllowCredentials: true, + MaxAge: 300, + }) +} +``` + +- Пакет: `github.com/go-chi/cors` + +#### Recovery + +- Ловит panic в хэндлерах → логирует stack trace → возвращает 500 +- Не даёт серверу упасть из-за одного запроса + +### 0.3.3 Healthcheck + +``` +GET /health → 200 OK +{ + "status": "ok", + "version": "0.1.0", + "db": "connected" // Проверка pool.Ping() +} +``` + +--- + +## 0.4 Firebase Auth + JWT + +### 0.4.1 Firebase Admin SDK + +```go +// internal/auth/firebase.go + +type FirebaseAuth struct { + client *firebaseAuth.Client +} + +func NewFirebaseAuth(credentialsFile string) (*FirebaseAuth, error) { + opt := option.WithCredentialsFile(credentialsFile) + app, err := firebase.NewApp(context.Background(), nil, opt) + if err != nil { + return nil, fmt.Errorf("init firebase app: %w", err) + } + + client, err := app.Auth(context.Background()) + if err != nil { + return nil, fmt.Errorf("init auth client: %w", err) + } + + return &FirebaseAuth{client: client}, nil +} + +func (f *FirebaseAuth) VerifyToken(ctx context.Context, idToken string) (*firebaseAuth.Token, error) { + return f.client.VerifyIDToken(ctx, idToken) +} +``` + +- Пакет: `firebase.google.com/go/v4` +- Файл `firebase-credentials.json` — НЕ коммитится, путь через env + +### 0.4.2 JWT-генерация + +```go +// internal/auth/jwt.go + +type JWTManager struct { + secret []byte + accessDuration time.Duration + refreshDuration time.Duration +} + +type Claims struct { + UserID string `json:"user_id"` + Plan string `json:"plan"` + jwt.RegisteredClaims +} + +func (j *JWTManager) GenerateAccessToken(userID, plan string) (string, error) { + claims := Claims{ + UserID: userID, + Plan: plan, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(j.accessDuration)), + IssuedAt: jwt.NewNumericDate(time.Now()), + }, + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString(j.secret) +} + +func (j *JWTManager) GenerateRefreshToken() (string, time.Time) { + token := uuid.NewString() + expiresAt := time.Now().Add(j.refreshDuration) + return token, expiresAt +} + +func (j *JWTManager) ValidateAccessToken(tokenStr string) (*Claims, error) { + token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (interface{}, error) { + return j.secret, nil + }) + if err != nil { + return nil, err + } + claims, ok := token.Claims.(*Claims) + if !ok || !token.Valid { + return nil, fmt.Errorf("invalid token") + } + return claims, nil +} +``` + +- Пакет: `github.com/golang-jwt/jwt/v5` +- Access token: 15 минут (short-lived) +- Refresh token: 30 дней, хранится в БД (users.refresh_token), ротируется при каждом refresh + +### 0.4.3 Auth middleware + +```go +// internal/middleware/auth.go + +func Auth(jwtManager *auth.JWTManager) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + header := r.Header.Get("Authorization") + if !strings.HasPrefix(header, "Bearer ") { + http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) + return + } + + tokenStr := strings.TrimPrefix(header, "Bearer ") + claims, err := jwtManager.ValidateAccessToken(tokenStr) + if err != nil { + http.Error(w, `{"error":"invalid token"}`, http.StatusUnauthorized) + return + } + + ctx := context.WithValue(r.Context(), userIDKey, claims.UserID) + ctx = context.WithValue(ctx, userPlanKey, claims.Plan) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} +``` + +### 0.4.4 Эндпоинты авторизации + +#### POST /auth/login + +``` +Request: +{ + "firebase_token": "" +} + +Логика: +1. firebase.VerifyToken(firebase_token) → uid, email, name, photo +2. Upsert user в БД (по firebase_uid) +3. Генерация access JWT + refresh token +4. Сохранение refresh token в БД +5. Возвращение токенов + профиля + +Response (200): +{ + "access_token": "eyJ...", + "refresh_token": "550e8400-...", + "expires_in": 900, + "user": { + "id": "...", + "email": "user@gmail.com", + "name": "Иван", + "avatar_url": "...", + "plan": "free", + "has_completed_onboarding": false + } +} +``` + +#### POST /auth/refresh + +``` +Request: +{ + "refresh_token": "550e8400-..." +} + +Логика: +1. Найти user по refresh_token в БД +2. Проверить token_expires_at > now() +3. Сгенерировать новые access + refresh tokens +4. Сохранить новый refresh token (ротация) +5. Старый refresh token перестаёт работать + +Response (200): +{ + "access_token": "eyJ...", + "refresh_token": "новый-uuid", + "expires_in": 900 +} + +Errors: +- 401 — невалидный или истёкший refresh token +``` + +#### POST /auth/logout + +``` +Логика: +1. Получить user_id из JWT (Auth middleware) +2. Обнулить refresh_token и token_expires_at в БД +3. Клиент удаляет токены из secure storage + +Response (200): +{ "status": "ok" } +``` + +--- + +## 0.5 Users сервис + +### 0.5.1 Доменная модель + +```go +// internal/user/model.go + +type User struct { + ID string `json:"id"` + FirebaseUID string `json:"-"` + Email string `json:"email"` + Name string `json:"name"` + AvatarURL *string `json:"avatar_url"` + HeightCM *int `json:"height_cm"` + WeightKG *float64 `json:"weight_kg"` + Age *int `json:"age"` + Gender *string `json:"gender"` + Activity *string `json:"activity"` + Goal *string `json:"goal"` + DailyCalories *int `json:"daily_calories"` + Plan string `json:"plan"` + Preferences json.RawMessage `json:"preferences"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type UpdateProfileRequest struct { + Name *string `json:"name"` + HeightCM *int `json:"height_cm"` + WeightKG *float64 `json:"weight_kg"` + Age *int `json:"age"` + Gender *string `json:"gender"` + Activity *string `json:"activity"` + Goal *string `json:"goal"` + Preferences *json.RawMessage `json:"preferences"` +} +``` + +### 0.5.2 Repository + +```go +// internal/user/repository.go + +type Repository struct { + pool *pgxpool.Pool +} + +func (r *Repository) UpsertByFirebaseUID(ctx context.Context, uid, email, name, avatarURL string) (*User, error) +func (r *Repository) GetByID(ctx context.Context, id string) (*User, error) +func (r *Repository) Update(ctx context.Context, id string, req UpdateProfileRequest) (*User, error) +func (r *Repository) SetRefreshToken(ctx context.Context, id, token string, expiresAt time.Time) error +func (r *Repository) FindByRefreshToken(ctx context.Context, token string) (*User, error) +func (r *Repository) ClearRefreshToken(ctx context.Context, id string) error +``` + +- Upsert: `INSERT ... ON CONFLICT (firebase_uid) DO UPDATE SET email = ..., updated_at = now()` +- Update: динамический SQL — обновляются только переданные поля (не NULL) +- При обновлении параметров тела → пересчёт daily_calories + +### 0.5.3 Service layer + +```go +// internal/user/service.go + +type Service struct { + repo *Repository +} + +func (s *Service) GetProfile(ctx context.Context, userID string) (*User, error) +func (s *Service) UpdateProfile(ctx context.Context, userID string, req UpdateProfileRequest) (*User, error) +``` + +- В `UpdateProfile`: если изменился height/weight/age/gender/activity/goal → пересчёт daily_calories +- Валидация: height 100–250, weight 30–300, age 10–120 + +### 0.5.4 HTTP-хэндлеры + +``` +GET /profile +→ Берёт user_id из контекста (Auth middleware) +→ 200 + User JSON + +PUT /profile +→ Парсит UpdateProfileRequest из body +→ Валидация +→ service.UpdateProfile() +→ 200 + обновлённый User JSON +→ 400 при невалидных данных +``` + +--- + +## 0.6 Docker Compose + Makefile + +### docker-compose.yml + +```yaml +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_DB: food_ai + POSTGRES_USER: food_ai + POSTGRES_PASSWORD: food_ai_local + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data + + app: + build: + context: . + dockerfile: Dockerfile + ports: + - "8080:8080" + environment: + DATABASE_URL: postgres://food_ai:food_ai_local@postgres:5432/food_ai?sslmode=disable + FIREBASE_CREDENTIALS_FILE: /app/firebase-credentials.json + JWT_SECRET: local-dev-secret-change-in-prod + ALLOWED_ORIGINS: http://localhost:3000,http://localhost:8080 + depends_on: + - postgres + volumes: + - ./firebase-credentials.json:/app/firebase-credentials.json:ro + +volumes: + pgdata: +``` + +### Dockerfile + +```dockerfile +# Build +FROM golang:1.23-alpine AS builder +WORKDIR /build +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -o /app/server ./cmd/server + +# Run +FROM alpine:3.19 +RUN apk add --no-cache ca-certificates +WORKDIR /app +COPY --from=builder /app/server . +COPY migrations ./migrations +EXPOSE 8080 +CMD ["./server"] +``` + +### Makefile + +```makefile +.PHONY: run test lint migrate-up migrate-down migrate-create docker-up docker-down + +# Запуск для разработки +run: + go run ./cmd/server + +# Тесты +test: + go test ./... -v -race -count=1 + +# Линтер +lint: + golangci-lint run ./... + +# Миграции +migrate-up: + goose -dir migrations postgres "$(DATABASE_URL)" up + +migrate-down: + goose -dir migrations postgres "$(DATABASE_URL)" down + +migrate-create: + goose -dir migrations create $(name) sql + +migrate-status: + goose -dir migrations postgres "$(DATABASE_URL)" status + +# Docker +docker-up: + docker compose up -d + +docker-down: + docker compose down + +docker-logs: + docker compose logs -f app +``` + +### .env.example + +```bash +# Database +DATABASE_URL=postgres://food_ai:food_ai_local@localhost:5432/food_ai?sslmode=disable + +# Firebase +FIREBASE_CREDENTIALS_FILE=./firebase-credentials.json + +# JWT +JWT_SECRET=change-me-in-production +JWT_ACCESS_DURATION=15m +JWT_REFRESH_DURATION=720h + +# Server +PORT=8080 +ALLOWED_ORIGINS=http://localhost:3000 +``` + +--- + +## 0.7 Инициализация Flutter-проекта + +### 0.7.1 Структура каталогов + +``` +client/ +├── lib/ +│ ├── main.dart +│ ├── app.dart # MaterialApp, тема, роутер +│ ├── core/ +│ │ ├── api/ +│ │ │ ├── api_client.dart # Dio + interceptors +│ │ │ ├── auth_interceptor.dart # JWT attach + refresh +│ │ │ └── api_exceptions.dart # Типизированные ошибки +│ │ ├── auth/ +│ │ │ ├── auth_provider.dart # Riverpod provider состояния авторизации +│ │ │ ├── auth_service.dart # Firebase + backend auth +│ │ │ └── secure_storage.dart # Обёртка flutter_secure_storage +│ │ ├── router/ +│ │ │ └── app_router.dart # go_router конфигурация +│ │ └── theme/ +│ │ ├── app_theme.dart # ThemeData +│ │ └── app_colors.dart # Палитра +│ ├── features/ +│ │ ├── auth/ +│ │ │ ├── login_screen.dart +│ │ │ └── register_screen.dart +│ │ ├── home/ +│ │ │ └── home_screen.dart # Заглушка +│ │ ├── products/ +│ │ │ └── products_screen.dart # Заглушка +│ │ ├── menu/ +│ │ │ └── menu_screen.dart # Заглушка +│ │ ├── recipes/ +│ │ │ └── recipes_screen.dart # Заглушка +│ │ └── profile/ +│ │ └── profile_screen.dart # Заглушка +│ └── shared/ +│ ├── models/ +│ │ └── user.dart # Модель User + fromJson/toJson +│ └── widgets/ +│ └── ... # Общие виджеты (позже) +├── pubspec.yaml +├── analysis_options.yaml +└── firebase.json +``` + +### 0.7.2 Основные пакеты + +```yaml +# pubspec.yaml + +dependencies: + flutter: + sdk: flutter + + # Навигация + go_router: ^14.0.0 + + # Состояние + flutter_riverpod: ^2.5.0 + riverpod_annotation: ^2.3.0 + + # Сеть + dio: ^5.4.0 + + # Firebase + firebase_core: ^3.0.0 + firebase_auth: ^5.0.0 + google_sign_in: ^6.2.0 + sign_in_with_apple: ^6.1.0 + + # Хранение + flutter_secure_storage: ^9.2.0 + + # Сериализация + json_annotation: ^4.9.0 + freezed_annotation: ^2.4.0 + + # UI + cached_network_image: ^3.3.0 + +dev_dependencies: + flutter_test: + sdk: flutter + build_runner: ^2.4.0 + json_serializable: ^6.8.0 + freezed: ^2.5.0 + riverpod_generator: ^2.4.0 + flutter_lints: ^4.0.0 +``` + +### 0.7.3 Тема и дизайн-токены + +```dart +// lib/core/theme/app_colors.dart + +abstract class AppColors { + // Primary + static const primary = Color(0xFF4CAF50); // Зелёный — еда, здоровье + static const primaryLight = Color(0xFF81C784); + static const primaryDark = Color(0xFF388E3C); + + // Accent + static const accent = Color(0xFFFF9800); // Оранжевый — CTA, важное + + // Background + static const background = Color(0xFFF5F5F5); + static const surface = Color(0xFFFFFFFF); + + // Text + static const textPrimary = Color(0xFF212121); + static const textSecondary = Color(0xFF757575); + + // Status + static const error = Color(0xFFE53935); + static const warning = Color(0xFFFFA726); + static const success = Color(0xFF66BB6A); + + // Shelf life indicators + static const freshGreen = Color(0xFF4CAF50); // > 3 дня + static const warningYellow = Color(0xFFFFC107); // 1-3 дня + static const expiredRed = Color(0xFFE53935); // 0 дней / просрочено +} +``` + +```dart +// lib/core/theme/app_theme.dart + +ThemeData appTheme() { + return ThemeData( + useMaterial3: true, + colorSchemeSeed: AppColors.primary, + scaffoldBackgroundColor: AppColors.background, + appBarTheme: const AppBarTheme( + centerTitle: true, + elevation: 0, + backgroundColor: AppColors.surface, + foregroundColor: AppColors.textPrimary, + ), + bottomNavigationBarTheme: const BottomNavigationBarThemeData( + selectedItemColor: AppColors.primary, + unselectedItemColor: AppColors.textSecondary, + type: BottomNavigationBarType.fixed, + ), + inputDecorationTheme: InputDecorationTheme( + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + minimumSize: const Size(double.infinity, 48), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + ), + ); +} +``` + +--- + +## 0.8 Firebase Auth (Flutter) + +### 0.8.1 Firebase-проект + +Необходимые действия (вне кода): +1. Создать проект в Firebase Console +2. Добавить Android-приложение (package name, SHA-1) +3. Добавить iOS-приложение (bundle ID) +4. Добавить Web-приложение +5. Включить провайдеры: Email/Password, Google, Apple +6. Скачать `google-services.json` (Android), `GoogleService-Info.plist` (iOS) +7. Сгенерировать `firebase-credentials.json` (Service Account) для Go backend + +### 0.8.2 Auth Service + +```dart +// lib/core/auth/auth_service.dart + +class AuthService { + final FirebaseAuth _firebaseAuth; + final ApiClient _apiClient; + final SecureStorageService _storage; + + /// Вход через Google + Future signInWithGoogle() async { + final googleUser = await GoogleSignIn().signIn(); + final googleAuth = await googleUser!.authentication; + final credential = GoogleAuthProvider.credential( + accessToken: googleAuth.accessToken, + idToken: googleAuth.idToken, + ); + final userCredential = await _firebaseAuth.signInWithCredential(credential); + return _authenticateWithBackend(userCredential); + } + + /// Вход через Apple + Future signInWithApple() async { + final appleProvider = AppleAuthProvider() + ..addScope('email') + ..addScope('name'); + final userCredential = await _firebaseAuth.signInWithProvider(appleProvider); + return _authenticateWithBackend(userCredential); + } + + /// Вход через email/password + Future signInWithEmail(String email, String password) async { + final userCredential = await _firebaseAuth.signInWithEmailAndPassword( + email: email, password: password, + ); + return _authenticateWithBackend(userCredential); + } + + /// Регистрация через email/password + Future registerWithEmail(String email, String password, String name) async { + final userCredential = await _firebaseAuth.createUserWithEmailAndPassword( + email: email, password: password, + ); + await userCredential.user!.updateDisplayName(name); + return _authenticateWithBackend(userCredential); + } + + /// Общая логика: Firebase → Backend → JWT + Future _authenticateWithBackend(UserCredential credential) async { + final idToken = await credential.user!.getIdToken(); + + final response = await _apiClient.post('/auth/login', data: { + 'firebase_token': idToken, + }); + + await _storage.saveTokens( + accessToken: response['access_token'], + refreshToken: response['refresh_token'], + ); + + return User.fromJson(response['user']); + } + + Future signOut() async { + await _apiClient.post('/auth/logout'); + await _firebaseAuth.signOut(); + await _storage.clearTokens(); + } +} +``` + +### 0.8.3 Экран входа + +``` +┌─────────────────────────────────────┐ +│ │ +│ [Лого FoodAI] │ +│ │ +│ Управляйте питанием │ +│ с помощью AI │ +│ │ +│ ┌───────────────────────────────┐ │ +│ │ 📧 Email │ │ +│ └───────────────────────────────┘ │ +│ ┌───────────────────────────────┐ │ +│ │ 🔒 Пароль │ │ +│ └───────────────────────────────┘ │ +│ │ +│ ┌───────────────────────────────┐ │ +│ │ Войти │ │ +│ └───────────────────────────────┘ │ +│ │ +│ ── или ── │ +│ │ +│ ┌───────────────────────────────┐ │ +│ │ [G] Войти через Google │ │ +│ └───────────────────────────────┘ │ +│ ┌───────────────────────────────┐ │ +│ │ [] Войти через Apple │ │ +│ └───────────────────────────────┘ │ +│ │ +│ Нет аккаунта? Регистрация │ +│ │ +└─────────────────────────────────────┘ +``` + +- Кнопка Apple отображается только на iOS +- Валидация: email-формат, пароль минимум 6 символов +- Ошибки: «Неверный email или пароль», «Email уже зарегистрирован», «Нет соединения» + +### 0.8.4 Secure Storage + +```dart +// lib/core/auth/secure_storage.dart + +class SecureStorageService { + final FlutterSecureStorage _storage; + + Future saveTokens({ + required String accessToken, + required String refreshToken, + }) async { + await _storage.write(key: 'access_token', value: accessToken); + await _storage.write(key: 'refresh_token', value: refreshToken); + } + + Future getAccessToken() => _storage.read(key: 'access_token'); + Future getRefreshToken() => _storage.read(key: 'refresh_token'); + + Future clearTokens() async { + await _storage.delete(key: 'access_token'); + await _storage.delete(key: 'refresh_token'); + } +} +``` + +--- + +## 0.9 Навигация и каркас + +### 0.9.1 go_router + Shell Route + +```dart +// lib/core/router/app_router.dart + +final appRouter = GoRouter( + initialLocation: '/home', + redirect: (context, state) { + final isLoggedIn = /* check auth state */; + final isAuthRoute = state.matchedLocation.startsWith('/auth'); + + if (!isLoggedIn && !isAuthRoute) return '/auth/login'; + if (isLoggedIn && isAuthRoute) return '/home'; + return null; + }, + routes: [ + // Auth routes (без bottom bar) + GoRoute(path: '/auth/login', builder: (_, __) => const LoginScreen()), + GoRoute(path: '/auth/register', builder: (_, __) => const RegisterScreen()), + + // Main shell с Bottom Tab Bar + ShellRoute( + builder: (context, state, child) => MainShell(child: child), + routes: [ + GoRoute(path: '/home', builder: (_, __) => const HomeScreen()), + GoRoute(path: '/products', builder: (_, __) => const ProductsScreen()), + GoRoute(path: '/menu', builder: (_, __) => const MenuScreen()), + GoRoute(path: '/recipes', builder: (_, __) => const RecipesScreen()), + GoRoute(path: '/profile', builder: (_, __) => const ProfileScreen()), + ], + ), + ], +); +``` + +### 0.9.2 Main Shell (Bottom Tab Bar) + +```dart +class MainShell extends StatelessWidget { + final Widget child; + + @override + Widget build(BuildContext context) { + return Scaffold( + body: child, + bottomNavigationBar: BottomNavigationBar( + currentIndex: _calculateIndex(GoRouterState.of(context).matchedLocation), + onTap: (index) => _onTabTap(context, index), + items: const [ + BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Главная'), + BottomNavigationBarItem(icon: Icon(Icons.kitchen), label: 'Продукты'), + BottomNavigationBarItem(icon: Icon(Icons.calendar_month), label: 'Меню'), + BottomNavigationBarItem(icon: Icon(Icons.menu_book), label: 'Рецепты'), + BottomNavigationBarItem(icon: Icon(Icons.person), label: 'Профиль'), + ], + ), + ); + } +} +``` + +### 0.9.3 Экраны-заглушки + +Каждый экран-заглушка: + +```dart +class HomeScreen extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Главная')), + body: const Center( + child: Text('Раздел в разработке'), + ), + ); + } +} +``` + +--- + +## 0.10 API-клиент (Flutter) + +### 0.10.1 Dio-клиент + +```dart +// lib/core/api/api_client.dart + +class ApiClient { + late final Dio _dio; + + ApiClient({required String baseUrl, required SecureStorageService storage}) { + _dio = Dio(BaseOptions( + baseUrl: baseUrl, + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 30), + headers: {'Content-Type': 'application/json'}, + )); + + _dio.interceptors.addAll([ + AuthInterceptor(storage: storage, dio: _dio), + LogInterceptor(requestBody: true, responseBody: true), + ]); + } + + Future> get(String path, {Map? params}) async { + final response = await _dio.get(path, queryParameters: params); + return response.data; + } + + Future> post(String path, {dynamic data}) async { + final response = await _dio.post(path, data: data); + return response.data; + } + + Future> put(String path, {dynamic data}) async { + final response = await _dio.put(path, data: data); + return response.data; + } +} +``` + +### 0.10.2 Auth Interceptor + +```dart +// lib/core/api/auth_interceptor.dart + +class AuthInterceptor extends Interceptor { + final SecureStorageService _storage; + final Dio _dio; + + @override + void onRequest(RequestOptions options, RequestInterceptorHandler handler) async { + // Не добавлять токен к auth-эндпоинтам + if (options.path.startsWith('/auth/')) { + return handler.next(options); + } + + final token = await _storage.getAccessToken(); + if (token != null) { + options.headers['Authorization'] = 'Bearer $token'; + } + handler.next(options); + } + + @override + void onError(DioException err, ErrorInterceptorHandler handler) async { + if (err.response?.statusCode == 401) { + // Попытка refresh + final refreshToken = await _storage.getRefreshToken(); + if (refreshToken == null) return handler.next(err); + + try { + final response = await _dio.post('/auth/refresh', data: { + 'refresh_token': refreshToken, + }); + + await _storage.saveTokens( + accessToken: response.data['access_token'], + refreshToken: response.data['refresh_token'], + ); + + // Повторить оригинальный запрос с новым токеном + final retryOptions = err.requestOptions; + retryOptions.headers['Authorization'] = 'Bearer ${response.data['access_token']}'; + final retryResponse = await _dio.fetch(retryOptions); + return handler.resolve(retryResponse); + } catch (_) { + // Refresh не удался → выход + await _storage.clearTokens(); + return handler.next(err); + } + } + handler.next(err); + } +} +``` + +### 0.10.3 Модель User + +```dart +// lib/shared/models/user.dart + +@freezed +class User with _$User { + const factory User({ + required String id, + required String email, + required String name, + String? avatarUrl, + int? heightCm, + double? weightKg, + int? age, + String? gender, + String? activity, + String? goal, + int? dailyCalories, + required String plan, + @Default({}) Map preferences, + }) = _User; + + factory User.fromJson(Map json) => _$UserFromJson(json); +} +``` + +--- + +## Порядок выполнения + +``` +Фаза 1 — Backend core (0.1 → 0.2 → 0.3) + 0.1.1 Инициализация Go-модуля и каталогов + 0.1.2 Конфигурация + 0.1.3 Логирование + 0.1.4 Graceful shutdown + 0.2.1 Подключение pgxpool + 0.2.2 Goose миграции + 0.2.3 Миграция users + 0.3.1 chi-роутер + 0.3.2 Middleware + 0.3.3 Healthcheck + ─── Контрольная точка: make run → GET /health → 200 ─── + +Фаза 2 — Auth (0.4 → 0.5) + 0.4.1 Firebase Admin SDK + 0.4.2 JWT-генерация + 0.4.3 Auth middleware + 0.4.4 POST /auth/login + 0.4.5 POST /auth/refresh + 0.4.6 POST /auth/logout + 0.5.1 Модель User + 0.5.2 Repository + 0.5.3 Service + 0.5.4 GET/PUT /profile + ─── Контрольная точка: curl POST /auth/login → JWT → GET /profile → 200 ─── + +Фаза 3 — DevOps (0.6) + docker-compose.yml + Dockerfile + Makefile + .env.example + ─── Контрольная точка: docker compose up → app + postgres работают ─── + +Фаза 4 — Flutter (0.7 → 0.8 → 0.9 → 0.10) + 0.7.1 Инициализация проекта + 0.7.2 Пакеты + 0.7.3 Тема + 0.8.1 Firebase-конфигурация + 0.8.2 Экран входа + 0.8.3 Экран регистрации + 0.8.4 Secure storage + 0.9.1 go_router + auth guard + 0.9.2 Main Shell + заглушки + 0.10.1 Dio-клиент + 0.10.2 Auth interceptor + 0.10.3 Модель User + ─── Контрольная точка: вход через Google → 5 вкладок ─── +``` + +--- + +## Критерии готовности + +| Критерий | Проверка | +|----------|----------| +| Backend запускается и отвечает на /health | `curl localhost:8080/health` → 200 | +| Миграция users применяется без ошибок | `make migrate-up` → success | +| Регистрация через email работает | POST /auth/login с Firebase token → JWT | +| Refresh token ротируется | POST /auth/refresh → новая пара токенов | +| Профиль сохраняется | PUT /profile → GET /profile → данные совпадают | +| Docker Compose поднимает всё | `docker compose up` → app + postgres | +| Flutter собирается (Android) | `flutter run` → приложение запускается | +| Вход через Google работает | Тап → Google → вход → 5 вкладок | +| Auth guard работает | Без токена → редирект на экран входа | +| Token refresh работает | Через 15 мин → запрос → auto-refresh → 200 | + +--- + +## Тестирование + +### Моки и интерфейсы + +Для unit-тестирования сервисов и хэндлеров необходимо вынести зависимости за интерфейсы. Моки генерируются через `github.com/stretchr/testify/mock` или `go.uber.org/mock` (gomock). + +#### Интерфейсы для моков (Go) + +```go +// internal/auth/interfaces.go + +// TokenVerifier — верификация Firebase idToken +type TokenVerifier interface { + VerifyToken(ctx context.Context, idToken string) (uid, email, name, avatarURL string, err error) +} + +// JWTGenerator — генерация и валидация JWT +type JWTGenerator interface { + GenerateAccessToken(userID, plan string) (string, error) + GenerateRefreshToken() (token string, expiresAt time.Time) + ValidateAccessToken(tokenStr string) (*Claims, error) +} +``` + +```go +// internal/user/interfaces.go + +// UserRepository — доступ к данным пользователей +type UserRepository interface { + UpsertByFirebaseUID(ctx context.Context, uid, email, name, avatarURL string) (*User, error) + GetByID(ctx context.Context, id string) (*User, error) + Update(ctx context.Context, id string, req UpdateProfileRequest) (*User, error) + SetRefreshToken(ctx context.Context, id, token string, expiresAt time.Time) error + FindByRefreshToken(ctx context.Context, token string) (*User, error) + ClearRefreshToken(ctx context.Context, id string) error +} +``` + +#### Моки (Go) + +```go +// internal/auth/mocks/token_verifier.go + +type MockTokenVerifier struct { + mock.Mock +} + +func (m *MockTokenVerifier) VerifyToken(ctx context.Context, idToken string) (string, string, string, string, error) { + args := m.Called(ctx, idToken) + return args.String(0), args.String(1), args.String(2), args.String(3), args.Error(4) +} +``` + +```go +// internal/user/mocks/repository.go + +type MockUserRepository struct { + mock.Mock +} + +func (m *MockUserRepository) UpsertByFirebaseUID(ctx context.Context, uid, email, name, avatarURL string) (*user.User, error) { + args := m.Called(ctx, uid, email, name, avatarURL) + return args.Get(0).(*user.User), args.Error(1) +} + +func (m *MockUserRepository) GetByID(ctx context.Context, id string) (*user.User, error) { + args := m.Called(ctx, id) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*user.User), args.Error(1) +} + +// ... остальные методы аналогично +``` + +#### Моки (Flutter) + +Пакет `mockito` + code generation (`@GenerateMocks`). + +```dart +// test/mocks.dart + +@GenerateMocks([ + FirebaseAuth, + GoogleSignIn, + ApiClient, + SecureStorageService, +]) +import 'mocks.mocks.dart'; +``` + +Для каждого зависимого класса создаётся `Mock*` через `build_runner`: + +| Класс | Мок | Используется в тестах | +|-------|-----|----------------------| +| `FirebaseAuth` | `MockFirebaseAuth` | AuthService | +| `GoogleSignIn` | `MockGoogleSignIn` | AuthService (Google flow) | +| `ApiClient` | `MockApiClient` | AuthService, AuthInterceptor | +| `SecureStorageService` | `MockSecureStorageService` | AuthInterceptor, AuthService | + +Flutter dev-зависимости для тестов: + +```yaml +dev_dependencies: + mockito: ^5.4.0 + build_runner: ^2.4.0 # уже есть + network_image_mock: ^2.1.0 +``` + +--- + +### Unit-тесты (Go) + +#### JWT Manager — `internal/auth/jwt_test.go` + +| # | Тест-кейс | Что проверяем | +|---|-----------|---------------| +| 1 | Генерация access token | `GenerateAccessToken("user-1", "free")` → непустая строка, парсится без ошибки | +| 2 | Claims содержат правильные данные | Парсим сгенерированный токен → `claims.UserID == "user-1"`, `claims.Plan == "free"` | +| 3 | Токен истекает через заданное время | Генерируем с duration=1s → ждём 2s → `ValidateAccessToken` → ошибка expired | +| 4 | Невалидная подпись | Токен подписан другим секретом → `ValidateAccessToken` → ошибка | +| 5 | Мусор вместо токена | `ValidateAccessToken("not-a-token")` → ошибка | +| 6 | Пустая строка | `ValidateAccessToken("")` → ошибка | +| 7 | Генерация refresh token | `GenerateRefreshToken()` → непустая строка UUID, `expiresAt` > now | + +**Моки:** нет (чистая логика, без зависимостей). + +#### Auth Service — `internal/auth/service_test.go` + +| # | Тест-кейс | Что проверяем | Моки | +|---|-----------|---------------|------| +| 1 | Login: новый пользователь | Firebase token валиден → upsert создаёт юзера → возвращает JWT + refresh + user | `MockTokenVerifier`, `MockUserRepository` | +| 2 | Login: существующий пользователь | Firebase token валиден → upsert обновляет юзера → возвращает JWT | `MockTokenVerifier`, `MockUserRepository` | +| 3 | Login: невалидный Firebase token | `VerifyToken` возвращает ошибку → 401 | `MockTokenVerifier` (returns error) | +| 4 | Login: пустой firebase_token | Валидация на входе → 400 | — | +| 5 | Login: ошибка БД при upsert | `UpsertByFirebaseUID` возвращает ошибку → 500 | `MockTokenVerifier`, `MockUserRepository` (returns error) | +| 6 | Refresh: валидный token | Находим юзера по refresh → не истёк → генерируем новую пару → ротация | `MockUserRepository` | +| 7 | Refresh: истёкший token | `FindByRefreshToken` → `token_expires_at` < now → 401 | `MockUserRepository` | +| 8 | Refresh: несуществующий token | `FindByRefreshToken` → nil → 401 | `MockUserRepository` (returns nil) | +| 9 | Refresh: ротация — старый перестаёт работать | После refresh → `SetRefreshToken` вызван с новым token | `MockUserRepository` | +| 10 | Logout: успешный | `ClearRefreshToken` вызван с правильным user_id | `MockUserRepository` | + +#### User Service — `internal/user/service_test.go` + +| # | Тест-кейс | Что проверяем | Моки | +|---|-----------|---------------|------| +| 1 | GetProfile: существующий пользователь | `GetByID("user-1")` → User с правильными полями | `MockUserRepository` | +| 2 | GetProfile: несуществующий пользователь | `GetByID("unknown")` → not found error | `MockUserRepository` (returns nil) | +| 3 | UpdateProfile: обновление имени | `Update` вызван с `Name: "Новое имя"`, остальные поля не изменены | `MockUserRepository` | +| 4 | UpdateProfile: пересчёт калорий | Изменили height + weight + age + gender + activity + goal → `daily_calories` пересчитан по Mifflin-St Jeor | `MockUserRepository` | +| 5 | UpdateProfile: пересчёт калорий при частичном обновлении | Изменили только weight → нужно дочитать остальные параметры из existing user → пересчитать | `MockUserRepository` | +| 6 | UpdateProfile: без параметров тела — калории не пересчитываются | Обновили только имя → `daily_calories` не изменился | `MockUserRepository` | +| 7 | Валидация: height < 100 | `UpdateProfile` с height=50 → ошибка валидации | — | +| 8 | Валидация: height > 250 | `UpdateProfile` с height=300 → ошибка валидации | — | +| 9 | Валидация: weight < 30 | → ошибка валидации | — | +| 10 | Валидация: weight > 300 | → ошибка валидации | — | +| 11 | Валидация: age < 10 | → ошибка валидации | — | +| 12 | Валидация: age > 120 | → ошибка валидации | — | +| 13 | Валидация: невалидный gender | → ошибка валидации | — | +| 14 | Валидация: невалидный goal | → ошибка валидации | — | + +#### Расчёт калорий — `internal/user/calories_test.go` + +| # | Тест-кейс | Вход | Ожидаемый результат | +|---|-----------|------|---------------------| +| 1 | Мужчина, похудение | male, 178cm, 75kg, 28лет, moderate, lose | BMR=1728 → TDEE=2678 → 2178 | +| 2 | Женщина, поддержание | female, 165cm, 60kg, 30лет, low, maintain | BMR=1322 → TDEE=1818 → 1818 | +| 3 | Мужчина, набор массы | male, 185cm, 90kg, 25лет, high, gain | BMR=1923 → TDEE=3317 → 3617 | +| 4 | Минимальные параметры | female, 100cm, 30kg, 10лет, low, maintain | Расчёт без ошибки, результат > 0 | +| 5 | Максимальные параметры | male, 250cm, 300kg, 120лет, high, gain | Расчёт без ошибки | +| 6 | Неполные данные (нет goal) | все параметры кроме goal → nil | Возвращаем nil (нельзя рассчитать) | +| 7 | Неполные данные (нет weight) | → nil | nil | + +**Моки:** нет (чистая функция). + +#### Middleware — `internal/middleware/auth_test.go` + +| # | Тест-кейс | Что проверяем | +|---|-----------|---------------| +| 1 | Валидный токен | `Authorization: Bearer ` → handler вызван, user_id в контексте | +| 2 | Нет заголовка Authorization | → 401, handler не вызван | +| 3 | Заголовок без Bearer | `Authorization: ` → 401 | +| 4 | Истёкший токен | → 401 | +| 5 | Невалидный токен | → 401 | +| 6 | User ID в контексте | После успешной проверки — `UserIDFromCtx(ctx)` возвращает правильный ID | +| 7 | User Plan в контексте | После успешной проверки — `UserPlanFromCtx(ctx)` возвращает "free" или "paid" | + +**Моки:** используется настоящий `JWTManager` с тестовым секретом (не мок — тестируем интеграцию middleware + JWT). + +#### Middleware — `internal/middleware/request_id_test.go` + +| # | Тест-кейс | Что проверяем | +|---|-----------|---------------| +| 1 | Запрос без X-Request-ID | Response содержит `X-Request-ID`, значение — валидный UUID | +| 2 | Запрос с X-Request-ID | Response содержит тот же `X-Request-ID`, что и запрос | +| 3 | Request ID в контексте | `RequestIDFromCtx(ctx)` в handler возвращает правильный ID | + +**Моки:** нет (`httptest.NewRecorder` + `httptest.NewRequest`). + +#### Middleware — `internal/middleware/recovery_test.go` + +| # | Тест-кейс | Что проверяем | +|---|-----------|---------------| +| 1 | Handler вызывает panic(string) | Сервер возвращает 500, не падает | +| 2 | Handler вызывает panic(error) | Сервер возвращает 500, не падает | +| 3 | Handler не паникует | Обычный ответ проходит без изменений | + +**Моки:** нет. + +--- + +### Integration-тесты (Go) — с реальной БД + +Для integration-тестов поднимается PostgreSQL через `testcontainers-go`. Миграции применяются перед тестами. + +```go +// internal/testutil/postgres.go + +func SetupTestDB(t *testing.T) *pgxpool.Pool { + ctx := context.Background() + + container, err := postgres.RunContainer(ctx, + testcontainers.WithImage("postgres:16-alpine"), + postgres.WithDatabase("food_ai_test"), + postgres.WithUsername("test"), + postgres.WithPassword("test"), + ) + require.NoError(t, err) + t.Cleanup(func() { container.Terminate(ctx) }) + + connStr, err := container.ConnectionString(ctx, "sslmode=disable") + require.NoError(t, err) + + pool, err := pgxpool.New(ctx, connStr) + require.NoError(t, err) + + // Применяем миграции + goose.SetDialect("postgres") + db, _ := sql.Open("pgx", connStr) + goose.Up(db, "../../migrations") + + return pool +} +``` + +Пакет: `github.com/testcontainers/testcontainers-go/modules/postgres` + +#### User Repository — `internal/user/repository_integration_test.go` + +| # | Тест-кейс | Что проверяем | +|---|-----------|---------------| +| 1 | UpsertByFirebaseUID: новый пользователь | INSERT → user создан, id != nil, email/name совпадают | +| 2 | UpsertByFirebaseUID: повторный вызов с тем же uid | UPDATE → email обновлён, id тот же | +| 3 | GetByID: существующий | Создаём → получаем → поля совпадают | +| 4 | GetByID: несуществующий | → nil, без ошибки (или ErrNotFound) | +| 5 | Update: обновление всех полей | Создаём → обновляем height, weight, name → GetByID → проверяем | +| 6 | Update: partial update (только name) | height/weight не изменились | +| 7 | Update: preferences JSONB | Сохраняем `{"cuisines":["russian"]}` → читаем → совпадает | +| 8 | SetRefreshToken + FindByRefreshToken | Сохраняем → ищем → находим правильного юзера | +| 9 | FindByRefreshToken: несуществующий | → nil | +| 10 | ClearRefreshToken | Сохраняем → очищаем → FindByRefreshToken → nil | +| 11 | Уникальность firebase_uid | Два INSERT с одним uid → conflict, upsert корректен | +| 12 | updated_at обновляется | Создаём → обновляем → updated_at > created_at | + +#### Handler (E2E) — `internal/auth/handler_integration_test.go` + +| # | Тест-кейс | Что проверяем | +|---|-----------|---------------| +| 1 | POST /auth/login → 200 | Полный flow: mock Firebase → upsert → JWT + refresh в response | +| 2 | POST /auth/login → 401 | Невалидный Firebase token → 401 | +| 3 | POST /auth/refresh → 200 | Используем refresh из login → получаем новую пару | +| 4 | POST /auth/refresh → 401 | Невалидный refresh → 401 | +| 5 | POST /auth/logout → 200 | После logout → refresh с тем же токеном → 401 | +| 6 | GET /profile → 200 | JWT из login → GET /profile → user data | +| 7 | GET /profile → 401 | Без токена → 401 | +| 8 | PUT /profile → 200 | Обновляем параметры → GET /profile → данные обновлены | +| 9 | PUT /profile → 400 | Невалидные данные (age=5) → 400 с описанием | +| 10 | GET /health → 200 | `status: ok`, `db: connected` | + +Для handler-тестов используется `httptest.NewServer` с реальным роутером, реальной БД (testcontainers) и **моком Firebase** (`MockTokenVerifier`). + +--- + +### Unit-тесты (Flutter) + +#### User model — `test/shared/models/user_test.dart` + +| # | Тест-кейс | Что проверяем | +|---|-----------|---------------| +| 1 | fromJson: полный объект | Все поля десериализуются корректно | +| 2 | fromJson: минимальный объект | Только required-поля (id, email, name, plan), nullable = null | +| 3 | fromJson: camelCase ↔ snake_case | `avatar_url` → `avatarUrl`, `daily_calories` → `dailyCalories` | +| 4 | toJson: roundtrip | `fromJson(toJson(user)) == user` | +| 5 | fromJson: неизвестные поля игнорируются | `{"id": "1", "email": "...", "unknown_field": 42}` → без ошибки | + +**Моки:** нет. + +#### AuthService — `test/core/auth/auth_service_test.dart` + +| # | Тест-кейс | Что проверяем | Моки | +|---|-----------|---------------|------| +| 1 | signInWithEmail: успех | Firebase → backend /auth/login → токены сохранены → User возвращён | `MockFirebaseAuth`, `MockApiClient`, `MockSecureStorageService` | +| 2 | signInWithEmail: неверный пароль | FirebaseAuth бросает `FirebaseAuthException` → пробрасывается наверх | `MockFirebaseAuth` (throws) | +| 3 | signInWithEmail: ошибка backend | Firebase OK, но /auth/login → DioException → ошибка | `MockFirebaseAuth`, `MockApiClient` (throws) | +| 4 | signInWithGoogle: успех | GoogleSignIn → credential → Firebase → backend → токены | `MockGoogleSignIn`, `MockFirebaseAuth`, `MockApiClient`, `MockSecureStorageService` | +| 5 | signInWithGoogle: отмена | `GoogleSignIn().signIn()` → null (юзер закрыл окно) → graceful error | `MockGoogleSignIn` (returns null) | +| 6 | signOut: успех | /auth/logout вызван → Firebase signOut → токены удалены | `MockApiClient`, `MockFirebaseAuth`, `MockSecureStorageService` | +| 7 | signOut: ошибка backend | /auth/logout fail → всё равно Firebase signOut + clear tokens (не блокируем) | `MockApiClient` (throws) | + +#### AuthInterceptor — `test/core/api/auth_interceptor_test.dart` + +| # | Тест-кейс | Что проверяем | Моки | +|---|-----------|---------------|------| +| 1 | onRequest: добавляет Bearer token | Запрос к /profile → заголовок `Authorization: Bearer ` | `MockSecureStorageService` | +| 2 | onRequest: пропускает /auth/ пути | Запрос к /auth/login → без Authorization | `MockSecureStorageService` | +| 3 | onRequest: нет токена | Storage возвращает null → запрос без Authorization | `MockSecureStorageService` (returns null) | +| 4 | onError 401: refresh и retry | 401 → refresh → новые токены сохранены → оригинальный запрос повторён | `MockSecureStorageService`, mock Dio adapter | +| 5 | onError 401: refresh провалился | 401 → refresh → DioException → токены очищены → ошибка пробрасывается | `MockSecureStorageService`, mock Dio adapter | +| 6 | onError 401: нет refresh token | 401 → storage null → ошибка пробрасывается без retry | `MockSecureStorageService` (returns null) | +| 7 | onError не-401 | 500 → interceptor не вмешивается, ошибка пробрасывается | — | + +#### SecureStorageService — `test/core/auth/secure_storage_test.dart` + +| # | Тест-кейс | Что проверяем | Моки | +|---|-----------|---------------|------| +| 1 | saveTokens + getAccessToken | Сохраняем → читаем → совпадает | `MockFlutterSecureStorage` | +| 2 | saveTokens + getRefreshToken | Сохраняем → читаем → совпадает | `MockFlutterSecureStorage` | +| 3 | clearTokens | Сохраняем → очищаем → get → null | `MockFlutterSecureStorage` | +| 4 | getAccessToken: пусто | Ничего не сохраняли → null | `MockFlutterSecureStorage` | + +--- + +### Widget-тесты (Flutter) + +#### LoginScreen — `test/features/auth/login_screen_test.dart` + +| # | Тест-кейс | Что проверяем | +|---|-----------|---------------| +| 1 | Рендеринг | Email + пароль поля, кнопки «Войти», «Google», «Регистрация» видны | +| 2 | Валидация: пустой email | Тап «Войти» → «Введите email» | +| 3 | Валидация: невалидный email | Вводим "abc" → «Некорректный email» | +| 4 | Валидация: короткий пароль | < 6 символов → «Минимум 6 символов» | +| 5 | Успешный вход | Заполняем → тап «Войти» → навигация на /home | +| 6 | Ошибка входа | AuthService throws → показывает snackbar с ошибкой | +| 7 | Загрузка | Тап «Войти» → кнопка показывает индикатор → после ответа скрывает | +| 8 | Переход на регистрацию | Тап «Регистрация» → навигация на /auth/register | +| 9 | Кнопка Apple: только iOS | На Android — не отображается, на iOS — отображается | + +**Моки:** `MockAuthService` (возвращает User или бросает ошибку). + +#### RegisterScreen — `test/features/auth/register_screen_test.dart` + +| # | Тест-кейс | Что проверяем | +|---|-----------|---------------| +| 1 | Рендеринг | Поля: имя, email, пароль, подтверждение. Кнопка «Зарегистрироваться» | +| 2 | Валидация: пустое имя | → «Введите имя» | +| 3 | Валидация: пароли не совпадают | → «Пароли не совпадают» | +| 4 | Успешная регистрация | → навигация на /home | +| 5 | Ошибка: email уже занят | AuthService throws → snackbar «Email уже зарегистрирован» | + +**Моки:** `MockAuthService`. + +#### MainShell — `test/core/navigation/main_shell_test.dart` + +| # | Тест-кейс | Что проверяем | +|---|-----------|---------------| +| 1 | Отображает 5 вкладок | Иконки: home, kitchen, calendar, menu_book, person | +| 2 | Подсветка активной вкладки | При location=/products → вкладка «Продукты» активна | +| 3 | Навигация при тапе | Тап «Рецепты» → GoRouter переходит на /recipes | + +**Моки:** нет (widget test с GoRouter). + +#### Auth Guard — `test/core/router/auth_guard_test.dart` + +| # | Тест-кейс | Что проверяем | +|---|-----------|---------------| +| 1 | Не авторизован → /auth/login | Открываем /home → redirect на /auth/login | +| 2 | Авторизован → нет redirect | Открываем /home → остаёмся на /home | +| 3 | Авторизован + /auth/login → /home | Открываем /auth/login → redirect на /home | + +**Моки:** `MockAuthProvider` (isLoggedIn: true/false). + +--- + +### Сводная таблица + +| Модуль | Unit | Integration | Widget | Всего | +|--------|------|-------------|--------|-------| +| **Go: JWT Manager** | 7 | — | — | 7 | +| **Go: Auth Service** | 10 | — | — | 10 | +| **Go: User Service** | 14 | — | — | 14 | +| **Go: Calories** | 7 | — | — | 7 | +| **Go: Auth Middleware** | 7 | — | — | 7 | +| **Go: Request ID MW** | 3 | — | — | 3 | +| **Go: Recovery MW** | 3 | — | — | 3 | +| **Go: User Repository** | — | 12 | — | 12 | +| **Go: Handlers (E2E)** | — | 10 | — | 10 | +| **Flutter: User model** | 5 | — | — | 5 | +| **Flutter: AuthService** | 7 | — | — | 7 | +| **Flutter: AuthInterceptor** | 7 | — | — | 7 | +| **Flutter: SecureStorage** | 4 | — | — | 4 | +| **Flutter: LoginScreen** | — | — | 9 | 9 | +| **Flutter: RegisterScreen** | — | — | 5 | 5 | +| **Flutter: MainShell** | — | — | 3 | 3 | +| **Flutter: Auth Guard** | — | — | 3 | 3 | +| **Итого** | **74** | **22** | **20** | **116** | + +### Необходимые тестовые зависимости + +#### Go + +``` +go get github.com/stretchr/testify # assert, require, mock +go get github.com/testcontainers/testcontainers-go # PostgreSQL в Docker для integration-тестов +go get github.com/testcontainers/testcontainers-go/modules/postgres +``` + +#### Flutter + +```yaml +dev_dependencies: + flutter_test: + sdk: flutter + mockito: ^5.4.0 + build_runner: ^2.4.0 + network_image_mock: ^2.1.0 +``` + +--- + +## Зависимости Go-пакетов + +``` +go get github.com/go-chi/chi/v5 +go get github.com/go-chi/cors +go get github.com/jackc/pgx/v5 +go get github.com/pressly/goose/v3 +go get github.com/kelseyhightower/envconfig +go get github.com/golang-jwt/jwt/v5 +go get github.com/google/uuid +go get firebase.google.com/go/v4 +go get google.golang.org/api/option +``` diff --git a/docs/plans/Summary.md b/docs/plans/Summary.md new file mode 100644 index 0000000..a155fcd --- /dev/null +++ b/docs/plans/Summary.md @@ -0,0 +1,499 @@ +# FoodAI — План реализации + +## Обзор итераций + +| # | Итерация | Цель | Зависит от | +|---|----------|------|------------| +| 0 | Фундамент | Go-проект, БД, авторизация, Flutter-каркас | — | +| 1 | Справочник ингредиентов и рецепты | Наполнение БД рецептами, маппинг ингредиентов | 0 | +| 2 | Управление продуктами | CRUD продуктов, сроки хранения | 0 | +| 3 | AI-ядро | Очереди, Gemini-адаптер, rate limiter, budget guard | 0 | +| 4 | AI-распознавание | OCR чека, фото продуктов, фото блюд | 2, 3 | +| 5 | Каталог рецептов | Поиск, фильтры, «из моих продуктов», замены | 1, 2 | +| 6 | Планирование меню | Меню на неделю, AI-генерация, список покупок | 3, 5 | +| 7 | Дневник питания | Записи, порции, трекер воды, калории | 5 | +| 8 | Режим готовки | Пошаговая готовка, таймеры | 5 | +| 9 | Рекомендации и статистика | Рекомендации на главной, графики, тренды | 6, 7 | +| 10 | Полировка | Онбординг, пустые состояния, уведомления, отзывы | 9 | + +## Карта зависимостей + +``` + ┌──────────────┐ + │ 0. Фундамент │ + └──────┬───────┘ + │ + ┌────────────────┼────────────────┐ + │ │ │ + ▼ ▼ ▼ + ┌────────────────┐ ┌───────────┐ ┌────────────────┐ + │ 1. Справочник │ │ 2. Продук-│ │ 3. AI-ядро │ + │ ингредиентов │ │ ты │ │ (очереди, │ + │ + рецепты │ │ │ │ Gemini) │ + └───────┬────────┘ └─────┬─────┘ └───────┬────────┘ + │ │ │ + │ ┌────┴────┐ │ + │ │ │ │ + │ │ ┌────┴──────────┘ + │ │ │ + │ ▼ ▼ + │ ┌──────────────────┐ + │ │ 4. AI-распозна- │ + │ │ вание │ + │ └──────────────────┘ + │ │ + └─────┬─────┘ + │ + ▼ + ┌────────────────┐ + │ 5. Каталог │ + │ рецептов │ + └───────┬────────┘ + │ + ┌──────────┼──────────┐ + │ │ │ + ▼ ▼ ▼ +┌──────────┐ ┌──────────┐ ┌──────────┐ +│ 6. Меню │ │ 7. Днев- │ │ 8. Режим │ +│ + список │ │ ник пита-│ │ готовки │ +│ покупок │ │ ния │ │ │ +└─────┬────┘ └─────┬────┘ └──────────┘ + │ │ + └──────┬─────┘ + │ + ▼ + ┌────────────────┐ + │ 9. Рекоменда- │ + │ ции + стат-ка │ + └───────┬────────┘ + │ + ▼ + ┌────────────────┐ + │ 10. Полировка │ + └────────────────┘ +``` + +**Параллельная разработка:** итерации 1, 2, 3 могут выполняться параллельно. Итерации 6, 7, 8 — тоже параллельно после завершения 5. + +--- + +## Итерация 0: Фундамент + +> **Детальный план:** [Iteration_0.md](./Iteration_0.md) + +**Цель:** развернуть скелет проекта, базу данных, авторизацию и каркас мобильного приложения. После итерации можно зарегистрироваться, войти и увидеть пустые экраны. + +**Зависимости:** нет. + +### User Stories + +#### Backend + +| ID | Story | Описание | +|----|-------|----------| +| 0.1 | Инициализация Go-проекта | Структура проекта (cmd/, internal/, pkg/), go.mod, конфигурация (envconfig), логгер (slog), graceful shutdown | +| 0.2 | PostgreSQL + миграции | Подключение к PostgreSQL (pgx), система миграций (goose или golang-migrate). Начальная миграция: таблица `users` | +| 0.3 | HTTP-сервер + роутер | HTTP-сервер (net/http или chi), middleware (CORS, request ID, logging, recovery), healthcheck endpoint | +| 0.4 | Firebase Auth интеграция | Firebase Admin SDK. Middleware для верификации Firebase idToken. Выдача собственного JWT. Эндпоинты: `POST /auth/login`, `POST /auth/refresh`, `POST /auth/logout` | +| 0.5 | Таблица users | Миграция: users (id, firebase_uid, email, name, avatar_url, параметры тела, цель, preferences JSONB, plan, created_at). CRUD-сервис | +| 0.6 | Docker Compose | docker-compose.yml: PostgreSQL, приложение. Makefile с основными командами (migrate, run, test) | + +#### Flutter + +| ID | Story | Описание | +|----|-------|----------| +| 0.7 | Инициализация Flutter-проекта | Создание проекта, структура (features/, core/, shared/), подключение основных пакетов (dio, riverpod/bloc, go_router) | +| 0.8 | Firebase Auth во Flutter | Пакеты firebase_auth, google_sign_in, sign_in_with_apple. Экраны: вход (email + Google + Apple), регистрация. Хранение JWT в secure storage | +| 0.9 | Навигация и каркас экранов | Bottom Tab Bar (5 вкладок), пустые заглушки для каждого экрана, роутинг | +| 0.10 | API-клиент | Dio-клиент с interceptors: JWT-токен, refresh, error handling. Базовые модели (User) | + +### Результат итерации +- Можно зарегистрироваться через email / Google / Apple +- Войти в приложение и увидеть 5 вкладок с заглушками +- Backend отвечает на healthcheck и auth-запросы +- БД содержит таблицу users с данными зарегистрированных пользователей + +--- + +## Итерация 1: Справочник ингредиентов и рецепты + +**Цель:** наполнить БД каноническими ингредиентами и рецептами из Spoonacular. Это фундамент для всех фичей, связанных с рецептами, поиском и маппингом продуктов. + +**Зависимости:** итерация 0. + +### User Stories + +| ID | Story | Описание | +|----|-------|----------| +| 1.1 | Таблица ingredient_mappings | Миграция: id, canonical_name, spoonacular_id, aliases (JSONB), category, default_unit, нутриенты на 100г, storage_days. Индексы: GIN по aliases, UNIQUE по spoonacular_id | +| 1.2 | Импорт ингредиентов из Spoonacular | CLI-команда / джоб: запрос Spoonacular Ingredient API → сохранение ~1000 базовых ингредиентов в ingredient_mappings | +| 1.3 | Таблица recipes | Миграция: id, source, spoonacular_id, title, description, cuisine, difficulty, prep_time_min, калории, БЖУ, servings, image_url, ingredients (JSONB), steps (JSONB), tags (JSONB), avg_rating, review_count, created_by. Индексы: GIN по ingredients, full-text по title | +| 1.4 | Импорт рецептов из Spoonacular | CLI-команда / джоб: импорт 5 000–10 000 популярных рецептов. Маппинг ингредиентов рецепта на ingredient_mappings через spoonacular_id | +| 1.5 | Перевод рецептов | Batch-джоб: перевод title, description, steps через Gemini Flash-Lite. Результат сохраняется в БД (поля title_ru, description_ru или отдельная таблица переводов) | +| 1.6 | Базовая локализация aliases | Перевод aliases топ-200 ингредиентов на русский. Batch через Gemini или ручной маппинг | + +### Результат итерации +- БД содержит ~1 000 ингредиентов с русскими алиасами и нутриентами +- БД содержит 5 000–10 000 рецептов с переводами, ингредиентами, шагами, нутриентами +- Каждый ингредиент рецепта связан с ingredient_mappings через spoonacular_id + +--- + +## Итерация 2: Управление продуктами + +**Цель:** пользователь может вести список своих продуктов вручную — добавлять, редактировать, удалять, отслеживать сроки. + +**Зависимости:** итерация 0. + +### User Stories + +#### Backend + +| ID | Story | Описание | +|----|-------|----------| +| 2.1 | Таблица products | Миграция: id, user_id, mapping_id (FK nullable), name, quantity, unit (ENUM), category, storage_days, added_at, expires_at (computed). Индексы по user_id, expires_at | +| 2.2 | Products CRUD API | `GET /products` (фильтры: category, expiring), `POST /products`, `PUT /products/{id}`, `DELETE /products/{id}`, `DELETE /products` (очистить все) | +| 2.3 | Частичное использование | `PATCH /products/{id}/consume` — уменьшить количество. Если количество = 0, предложить удаление | +| 2.4 | Массовое добавление | `POST /products/batch` — добавление нескольких продуктов за раз (после распознавания). Обработка дубликатов: проверка по mapping_id, предложение объединить | +| 2.5 | Дефолтные сроки хранения | `GET /products/storage-defaults`, `PUT /products/storage-defaults`. Хранение в user preferences (JSONB в таблице users) | +| 2.6 | Fuzzy matching при добавлении | При добавлении продукта — поиск по ingredient_mappings.aliases. Если найдено — автозаполнение: mapping_id, category, unit, storage_days, нутриенты | + +#### Flutter + +| ID | Story | Описание | +|----|-------|----------| +| 2.7 | Экран «Мои продукты» | Список продуктов по категориям, поиск, chip-фильтры, badge «осталось X дней» | +| 2.8 | Добавление/редактирование продукта | Форма: название, количество, единица, категория, период хранения. Выпадающее меню добавления (+) | +| 2.9 | Частичное использование | Модалка «Сколько осталось?» при свайпе или тапе | +| 2.10 | Очистить и перезаполнить | Контекстное меню (···) → подтверждение → очистка | +| 2.11 | Настройки сроков хранения | Экран из Профиля: список категорий с редактируемыми днями | +| 2.12 | Пустое состояние | Иллюстрация + CTA «Сфотографируйте продукты или сканируйте чек» | + +### Результат итерации +- Пользователь может вручную добавить продукты, указать количество и сроки +- Видит «осталось X дней» для каждого продукта +- Может частично использовать продукт, удалить, очистить всё +- При добавлении — автоматический подбор категории, единицы, срока через fuzzy match + +--- + +## Итерация 3: AI-ядро + +**Цель:** построить инфраструктуру для AI-запросов: очереди, rate limiter, budget guard, адаптер Gemini. После итерации можно отправлять AI-запросы через API с контролем расхода. + +**Зависимости:** итерация 0. + +### User Stories + +| ID | Story | Описание | +|----|-------|----------| +| 3.1 | AI Service интерфейсы | Go-интерфейсы: FoodRecognizer, RecipeGenerator, MenuPlanner, NutritionEstimator. Структуры запросов и ответов | +| 3.2 | Gemini-адаптер | Реализация интерфейсов через Gemini API (google/generative-ai-go). Structured JSON output. Обработка ошибок, retries | +| 3.3 | Таблица ai_tasks | Миграция: id, user_id, task_type, status, priority, input/output_tokens, estimated_cost, queue/process_time_ms, created_at, completed_at. Индексы по user_id, status, created_at | +| 3.4 | Priority Queue Manager | Две очереди (chan в Go): paid (N воркеров), free (1 воркер). Распределение RPM между очередями. Горутины-воркеры | +| 3.5 | Rate Limiter (per-user) | Token bucket на горутинах. Конфигурируемые лимиты по тарифу (free: 20 req/час, paid: 100 req/час). HTTP 429 при превышении | +| 3.6 | Budget Guard | Подсчёт дневных затрат по ai_tasks. Пороги: 80% → warn, 100% → free stop, 120% → all stop. Счётчик сбрасывается в полночь | +| 3.7 | AI API эндпоинты (заглушки) | `POST /ai/recognize-receipt`, `/ai/recognize-products`, `/ai/recognize-dish`, `/ai/suggest-recipes`, `/ai/generate-menu`, `/ai/substitute`. Возвращают task_id (HTTP 202). `GET /ai/tasks/{id}` для polling | +| 3.8 | Логирование и мониторинг | Каждый AI-запрос логируется в ai_tasks с токенами и стоимостью. Эндпоинт `/admin/ai-stats` для просмотра затрат | + +### Результат итерации +- AI-запросы проходят через очередь с приоритетами +- Paid-пользователи обслуживаются быстрее +- Расход бюджета контролируется, при превышении — graceful degradation +- Все запросы логируются с точной стоимостью +- API эндпоинты принимают запросы и возвращают результат через polling + +--- + +## Итерация 4: AI-распознавание + +**Цель:** пользователь может фотографировать чеки, продукты и блюда — AI распознаёт и предлагает результат для корректировки. + +**Зависимости:** итерации 2 (продукты), 3 (AI-ядро). + +### User Stories + +#### Backend + +| ID | Story | Описание | +|----|-------|----------| +| 4.1 | OCR чека | Реализация FoodRecognizer.RecognizeReceipt: фото → Gemini Flash (vision) → structured JSON (name, quantity, unit, category, price, confidence). Маппинг результатов на ingredient_mappings | +| 4.2 | Распознавание продуктов (фото) | FoodRecognizer.RecognizeProducts: фото → Gemini Flash → JSON. Поддержка мультифото (объединение результатов, дедупликация). Маппинг на ingredient_mappings | +| 4.3 | Распознавание блюда | FoodRecognizer.RecognizeDish: фото → Gemini Flash → dish_name, weight, calories, БЖУ, confidence. Full-text search по recipes.title для привязки к рецепту из БД | +| 4.4 | Авто-маппинг нераспознанных | Если fuzzy match по aliases не нашёл ингредиент → разовый запрос к Gemini: определить canonical_name → сохранить в ingredient_mappings. Следующий запрос с таким же продуктом — без AI | +| 4.5 | Загрузка фото | Эндпоинт для multipart upload фото. Сохранение в S3. Передача URL в AI-задачу | + +#### Flutter + +| ID | Story | Описание | +|----|-------|----------| +| 4.6 | Экран камеры (чек) | Видоискатель, кнопка съёмки, выбор из галереи. Отправка на backend | +| 4.7 | Экран камеры (еда) | Переключатель «Готовое блюдо» / «Продукты». Съёмка, отправка | +| 4.8 | Экран загрузки AI | Анимация «Распознаём...» с индикатором. Polling по task_id | +| 4.9 | Экран корректировки (чек/фото продуктов) | Список распознанных продуктов. Инлайн-редактирование: название, количество, единица, категория, срок хранения. Чекбоксы, удаление, добавление вручную, «Сделать ещё фото». Предупреждения о дубликатах. CTA «Добавить в мои продукты» | +| 4.10 | Экран результата (фото блюда) | Фото, название, калории, БЖУ. Подтверждение / корректировка. Слайдер порции. Выбор приёма пищи. CTA «Записать в дневник» | +| 4.11 | Обработка ошибок AI | Экран «Не удалось распознать» → «Переснять» / «Ввести вручную» | + +### Результат итерации +- Пользователь фотографирует чек → получает список продуктов → корректирует → добавляет в запасы +- Фотографирует холодильник (несколько фото) → то же +- Фотографирует блюдо → видит калории и БЖУ → может записать в дневник +- Нераспознанные ингредиенты автоматически добавляются в справочник + +--- + +## Итерация 5: Каталог рецептов + +**Цель:** пользователь может просматривать, искать и фильтровать рецепты. Видит, какие ингредиенты есть в запасах, а каких не хватает. Может добавить рецепт в избранное. + +**Зависимости:** итерации 1 (рецепты в БД), 2 (продукты для проверки наличия). + +### User Stories + +#### Backend + +| ID | Story | Описание | +|----|-------|----------| +| 5.1 | Поиск и фильтрация рецептов | `GET /recipes` — фильтры: cuisine, difficulty, prep_time, calories_max, meal_type, diet_tags. Full-text search по title. Пагинация (cursor-based) | +| 5.2 | «Из моих продуктов» | Фильтр: сопоставление ingredients[].mapping_id с products.mapping_id пользователя. Ранжирование по доле совпадения. На каждом рецепте: «Есть всё ✓» / «-N прод.» | +| 5.3 | Карточка рецепта с наличием | `GET /recipes/{id}` — рецепт + для каждого ингредиента: есть ✅ / нет ❌ / замена 🔄. Итог: «Всё есть» / «Не хватает N» | +| 5.4 | Замены ингредиентов | При ❌ — поиск замены: сначала в таблице ingredient_substitutions, затем (если нет) — запрос к Gemini, результат кешируется | +| 5.5 | Избранное | `POST /recipes/{id}/favorite`, `DELETE /recipes/{id}/favorite`. Таблица favorites (user_id, recipe_id). `GET /recipes?favorite=true` | +| 5.6 | Дозапрос Spoonacular | Если в локальной БД мало результатов по фильтрам — запрос к Spoonacular API (findByIngredients, complexSearch). Новые рецепты сохраняются в БД | + +#### Flutter + +| ID | Story | Описание | +|----|-------|----------| +| 5.7 | Экран каталога рецептов | Сетка 2 колонки, поиск, chip-фильтры, кнопка «Из моих продуктов», панель фильтров (bottom sheet), бесконечный скролл | +| 5.8 | Карточка рецепта | Фото, рейтинг, метаинформация (время/сложность/кухня), калории/БЖУ, регулятор порций, список ингредиентов с ✅/❌/🔄, описание. CTA «Начать готовить», «Добавить в меню» | +| 5.9 | Замены ингредиентов | Строка «→ Замена: пармезан (есть)» под ингредиентом с 🔄 | +| 5.10 | Кнопка «Добавить в список покупок» | Недостающие ингредиенты → формирование позиций для списка покупок | + +### Результат итерации +- Пользователь ищет рецепты, фильтрует по кухне/сложности/времени/калориям +- Видит, что можно приготовить из имеющихся продуктов +- Для каждого рецепта — отметки наличия ингредиентов и предложения замен +- Может добавить рецепт в избранное + +--- + +## Итерация 6: Планирование меню + +**Цель:** пользователь может составлять меню на неделю — вручную или через AI-генерацию. Формируется список покупок. + +**Зависимости:** итерации 3 (AI-ядро для генерации), 5 (каталог рецептов). + +### User Stories + +#### Backend + +| ID | Story | Описание | +|----|-------|----------| +| 6.1 | Таблицы menu_plans и menu_items | Миграции. menu_plans: id, user_id, week_start, template_name. menu_items: id, menu_plan_id, day_of_week, meal_type, recipe_id, servings | +| 6.2 | Menu CRUD | `GET /menu?week=`, `POST /menu/items`, `PUT /menu/items/{id}`, `DELETE /menu/items/{id}`. Подсчёт калорий за день/неделю | +| 6.3 | AI-генерация меню | `POST /ai/generate-menu`: backend отбирает кандидатов из БД (SQL по фильтрам + наличие ингредиентов) → формирует промпт с recipe_id → Gemini ранжирует → backend сохраняет menu_items | +| 6.4 | Шаблоны меню | `POST /menu/templates` (сохранить), `GET /menu/templates` (список), `POST /menu/from-template/{id}` (применить). История прошлых меню | +| 6.5 | Таблица shopping_lists | Миграция: id, user_id, menu_plan_id, items (JSONB). Автогенерация из меню: ингредиенты рецептов − имеющиеся продукты = список | +| 6.6 | Shopping list API | `GET /shopping-list`, `POST /shopping-list/generate`, `PUT /shopping-list/items/{idx}`, `PATCH /shopping-list/items/{idx}/check`, `POST /shopping-list/items` (ручная позиция) | + +#### Flutter + +| ID | Story | Описание | +|----|-------|----------| +| 6.7 | Экран меню | Понедельный календарь, слоты по приёмам пищи, калорийность за день, drag-and-drop, контекстное меню (···), пустые слоты с подсказками | +| 6.8 | Добавление блюда в слот | Модалка выбора дня + приёма пищи. Переход в каталог рецептов для выбора | +| 6.9 | AI-генерация | Кнопка ⚡ → экран параметров (период, кухня, сложность, из моих продуктов, калории) → генерация → отображение результата с возможностью заменить отдельные блюда | +| 6.10 | Шаблоны и история | Выпадающее меню: сохранить как шаблон, загрузить из шаблона, из истории | +| 6.11 | Экран списка покупок | Список по категориям, чекбоксы, свайп-удаление, ручное добавление, итого, «Поделиться», «Пересчитать из меню» | +| 6.12 | Переходный экран «Составить меню?» | После добавления продуктов (чек/фото) → предложение сгенерировать меню с выбором параметров | + +### Результат итерации +- Пользователь составляет меню на неделю — вручную или AI-генерацией +- AI подбирает рецепты из нашей БД с учётом продуктов, калорий, предпочтений +- Формируется список покупок (автоматически из меню − запасы) +- Можно сохранять шаблоны и повторять удачные меню +- После сканирования чека — плавный переход к генерации меню + +--- + +## Итерация 7: Дневник питания + +**Цель:** пользователь ведёт учёт съеденного — записывает приёмы пищи, отслеживает калории и БЖУ, регулирует порции. + +**Зависимости:** итерация 5 (рецепты для добавления из каталога). + +### User Stories + +#### Backend + +| ID | Story | Описание | +|----|-------|----------| +| 7.1 | Таблица meal_diary | Миграция: id, user_id, date, meal_type, recipe_id (nullable), name, portions, calories, protein, fat, carbs, source (menu/photo/manual/recipe), created_at | +| 7.2 | Diary CRUD | `GET /diary?date=`, `POST /diary`, `PUT /diary/{id}`, `DELETE /diary/{id}`. Подсчёт итогов дня (калории, БЖУ) | +| 7.3 | Из меню в дневник | При отметке «съедено» на главном экране → автосоздание записи в дневнике. Списание ингредиентов из продуктов | +| 7.4 | Трекер воды | Таблица water_tracker (user_id, date, glasses). `GET /stats/water?date=`, `PUT /stats/water` | +| 7.5 | База продуктов для быстрого поиска | Endpoint для поиска по ingredient_mappings: `GET /ingredients/search?q=банан` → название + нутриенты на порцию. Для перекусов без рецепта | + +#### Flutter + +| ID | Story | Описание | +|----|-------|----------| +| 7.6 | Экран дневника питания | Навигация по дням, круговой прогресс калорий, прогресс-бары БЖУ, приёмы пищи, порции, «+ Добавить» | +| 7.7 | Модалка добавления | Варианты: сфотографировать, из меню, из каталога, из избранного, быстрый поиск продукта, вручную | +| 7.8 | Указание порции | Слайдер 0.5x–3x при добавлении из рецепта. Пересчёт калорий/БЖУ | +| 7.9 | Быстрый поиск продукта | Поле поиска → результаты из ingredient_mappings → тап → добавить в дневник с указанием количества | +| 7.10 | Трекер воды | Ряд стаканов внизу дневника, тап = +1/-1 | +| 7.11 | Главный экран — карточка калорий | Круговой прогресс, тап → переход в дневник | +| 7.12 | Главный экран — «Сегодня в меню» | Список из menu_items на сегодня с чекбоксами «съедено» | + +### Результат итерации +- Пользователь записывает приёмы пищи: из меню, каталога, фото или вручную +- Регулирует порции, видит калории и БЖУ за день +- На главном экране — прогресс калорий и чекбоксы «съедено» +- Трекер воды + +--- + +## Итерация 8: Режим готовки + +**Цель:** пользователь готовит блюдо по пошаговой инструкции с таймерами. После завершения — запись в дневник и оценка. + +**Зависимости:** итерация 5 (карточка рецепта для запуска). + +### User Stories + +| ID | Story | Описание | +|----|-------|----------| +| 8.1 | Экран пошаговой готовки | Фото шага, заголовок, описание (крупный шрифт), навигация «Назад»/«Далее», свайп, точечный индикатор прогресса | +| 8.2 | Таймеры | Кнопка «Запустить таймер» на шагах с timer_seconds. Обратный отсчёт. Пауза, стоп. Несколько таймеров параллельно | +| 8.3 | Панель активных таймеров | Фиксирована внизу. Показывает все запущенные таймеры с оставшимся временем | +| 8.4 | Уведомления таймера | Push-уведомление + звук при завершении таймера. Модалка «Готово!» | +| 8.5 | Keep screen on | Экран не гаснет в режиме готовки (wakelock) | +| 8.6 | Закрытие с подтверждением | Кнопка ✕ → «Прервать готовку?» | +| 8.7 | Экран завершения | «Приятного аппетита!» → «Записать в дневник» (с выбором порций), «Оценить рецепт», «Поделиться фото». Автосписание ингредиентов из запасов | + +### Результат итерации +- Пользователь готовит по шагам с фото и описанием +- Запускает таймеры (параллельно), получает уведомления +- По завершении — запись в дневник, оценка рецепта, списание продуктов + +--- + +## Итерация 9: Рекомендации и статистика + +**Цель:** приложение проактивно рекомендует рецепты. Пользователь видит аналитику своего питания. + +**Зависимости:** итерации 6 (меню), 7 (дневник — данные для статистики). + +### User Stories + +#### Рекомендации + +| ID | Story | Описание | +|----|-------|----------| +| 9.1 | Рекомендации на главном экране | Карусель «Рекомендуем приготовить». Алгоритм: (1) рецепты из продуктов с истекающим сроком, (2) полное совпадение ингредиентов, (3) предпочтения кухни. Endpoint: `GET /recipes/recommended` | +| 9.2 | «Готовили недавно» | Секция на главном экране и в каталоге. Endpoint: `GET /recipes/recent` — последние 5 приготовленных (из meal_diary с source=recipe) | +| 9.3 | Секция «Для вас» в каталоге | Персональные рекомендации на основе: оценок, предпочтений кухонь, истории. Endpoint: `GET /recipes/recommended?section=personal` | +| 9.4 | Подсказки в пустых слотах меню | При пустом слоте меню — рекомендация на основе оставшихся калорий + продукты с истекающим сроком | + +#### Статистика + +| ID | Story | Описание | +|----|-------|----------| +| 9.5 | Endpoint статистики | `GET /stats?period=week|month|3months` — калории/БЖУ по дням, средние, тренды, самые частые блюда. Агрегация по meal_diary | +| 9.6 | Экран статистики | Переключатель периода, столбчатая диаграмма калорий, stacked bar БЖУ, тренды (↑↓→), топ блюд | +| 9.7 | Переход из главного экрана | Тап по прогресс-бару калорий → дневник или статистика | + +### Результат итерации +- На главном экране — рекомендации (приоритет на истекающие продукты) и «Готовили недавно» +- В каталоге — секция «Для вас» +- В меню — умные подсказки в пустых слотах +- Графики калорий и БЖУ за неделю/месяц/3 месяца + +--- + +## Итерация 10: Полировка + +**Цель:** довести приложение до продуктового качества — онбординг, пустые состояния, уведомления, отзывы, переходные экраны. + +**Зависимости:** итерация 9. + +### User Stories + +#### Онбординг + +| ID | Story | Описание | +|----|-------|----------| +| 10.1 | Экраны онбординга | 5 шагов: приветствие (свайп-карточки), параметры тела, цель + расчёт нормы, ограничения + предпочтения кухонь, предложение добавить продукты | +| 10.2 | Сохранение данных онбординга | `PUT /profile` с параметрами из онбординга. Сохранение предпочтений кухонь в preferences | +| 10.3 | Флаг прохождения онбординга | Показывать только при первом входе. Флаг в secure storage | + +#### Пустые состояния и ошибки + +| ID | Story | Описание | +|----|-------|----------| +| 10.4 | Пустые состояния всех экранов | Иллюстрация + текст + CTA для: продуктов, меню, дневника, статистики, рецептов (избранные) | +| 10.5 | Состояния ошибок | Нет сети (баннер + оффлайн-данные), ошибка AI (переснять / ввести вручную), ошибка сервера (повторить) | +| 10.6 | Toast с отменой | При удалении записи из дневника, продукта — toast «Удалено» + кнопка «Отменить» (5 сек) | + +#### Уведомления + +| ID | Story | Описание | +|----|-------|----------| +| 10.7 | Push-уведомления (FCM) | Интеграция Firebase Cloud Messaging. Flutter: запрос разрешений, обработка | +| 10.8 | Уведомления о сроках продуктов | Backend: cron-джоб утром → push «Молоко — осталось 1 день. Использовать в рецепте?» | +| 10.9 | Напоминания о приёмах пищи | По расписанию (настраиваемое): «Время обеда! В меню: ...» | +| 10.10 | Вечернее напоминание о воде | «Вы выпили 5 из 8 стаканов воды сегодня» | + +#### Отзывы + +| ID | Story | Описание | +|----|-------|----------| +| 10.11 | Таблица reviews | Миграция: id, user_id, recipe_id, rating, text, photo_url, created_at. Пересчёт avg_rating, review_count в recipes | +| 10.12 | API отзывов | `GET /recipes/{id}/reviews` (пагинация), `POST /recipes/{id}/reviews` | +| 10.13 | UI отзывов | Секция в карточке рецепта, модалка написания отзыва (звёзды + текст + фото), полный список отзывов | + +#### Профиль + +| ID | Story | Описание | +|----|-------|----------| +| 10.14 | Экран профиля | Аватар, параметры, цель, ограничения, предпочтения кухонь, ссылки (статистика, избранное, отзывы, сроки хранения, настройки) | +| 10.15 | Настройки приложения | Экран: уведомления (вкл/выкл по типам), тема (светлая/тёмная/системная), норма воды, язык | + +### Результат итерации +- Новый пользователь проходит онбординг и сразу получает персонализированный опыт +- Все экраны имеют осмысленные пустые состояния +- Ошибки обрабатываются gracefully +- Push-уведомления о сроках, приёмах пищи, воде +- Можно оставлять отзывы к рецептам +- Полноценный профиль с настройками + +--- + +## Итоги по объёму + +| Итерация | Backend stories | Flutter stories | Всего | +|----------|----------------|-----------------|-------| +| 0. Фундамент | 6 | 4 | 10 | +| 1. Ингредиенты + рецепты | 6 | 0 | 6 | +| 2. Продукты | 6 | 6 | 12 | +| 3. AI-ядро | 8 | 0 | 8 | +| 4. AI-распознавание | 5 | 6 | 11 | +| 5. Каталог рецептов | 6 | 4 | 10 | +| 6. Меню + покупки | 6 | 6 | 12 | +| 7. Дневник питания | 5 | 7 | 12 | +| 8. Режим готовки | 0 | 7 | 7 | +| 9. Рекомендации + стат-ка | 4 | 3 | 7 | +| 10. Полировка | 5 | 10 | 15 | +| **Итого** | **57** | **53** | **110** | + +## Приоритеты для MVP + +Минимально жизнеспособный продукт — итерации **0–6**: + +- Авторизация, продукты, AI-распознавание, рецепты, меню, список покупок +- Позволяет пройти основной пользовательский сценарий: купил продукты → сфотографировал чек → получил меню → составил список покупок +- **68 stories** из 110 (62%) + +Итерации 7–10 — расширение до полного продукта.