From cf69a4a3d944e1280344ca3d8d49525cd65aaf89 Mon Sep 17 00:00:00 2001 From: dbastrikin Date: Thu, 19 Mar 2026 16:11:21 +0200 Subject: [PATCH] feat: dish recognition job context, diary linkage, home widget, history page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - Rename recognition_jobs → dish_recognition_jobs; add target_date and target_meal_type columns to capture scan context at submission time - Add job_id FK on meal_diary so entries are linked to their origin job - New GET /ai/jobs endpoint returns today's unlinked jobs for the current user - diary.Entry and CreateRequest gain job_id field; repository reads/writes it - CORS middleware: allow Accept-Language and Cache-Control headers - Logging middleware: implement http.Flusher on responseWriter (needed for SSE) - Consolidate migrations into a single 001_initial_schema.sql Flutter: - POST /ai/recognize-dish now sends target_date and target_meal_type - DishResultSheet accepts jobId; _addToDiary includes it in the diary payload, saves last-used meal type to SharedPreferences, invalidates todayJobsProvider - TodayJobsNotifier + todayJobsProvider: loads unlinked jobs via GET /ai/jobs - Home screen shows _TodayJobsWidget (up to 3 tiles) between macros and meals; tapping a done tile reopens DishResultSheet with the stored result - Quick Actions row: third button "История" → /scan/history - New RecognitionHistoryScreen: full-screen list of today's unlinked jobs - LocalPreferences wrapper over SharedPreferences (last_used_meal_type) - app_theme: apply Google Fonts Roboto as default font family Co-Authored-By: Claude Sonnet 4.6 --- backend/internal/domain/diary/entity.go | 2 + backend/internal/domain/diary/repository.go | 12 +- .../internal/domain/recognition/handler.go | 42 ++++- backend/internal/domain/recognition/job.go | 39 +++-- .../domain/recognition/job_repository.go | 68 ++++++-- backend/internal/infra/middleware/cors.go | 2 +- backend/internal/infra/middleware/logging.go | 6 + backend/internal/infra/server/server.go | 1 + backend/migrations/001_initial_schema.sql | 54 ++++--- client/lib/core/router/app_router.dart | 5 + .../lib/core/storage/local_preferences.dart | 17 ++ .../storage/local_preferences_provider.dart | 9 ++ client/lib/core/theme/app_theme.dart | 2 + client/lib/features/home/home_provider.dart | 23 +++ client/lib/features/home/home_screen.dart | 152 +++++++++++++++++- .../lib/features/scan/dish_result_screen.dart | 6 + .../scan/recognition_history_screen.dart | 129 +++++++++++++++ .../features/scan/recognition_service.dart | 143 ++++++++++------ client/lib/main.dart | 14 +- client/pubspec.lock | 66 +++++++- client/pubspec.yaml | 3 + 21 files changed, 682 insertions(+), 113 deletions(-) create mode 100644 client/lib/core/storage/local_preferences.dart create mode 100644 client/lib/core/storage/local_preferences_provider.dart create mode 100644 client/lib/features/scan/recognition_history_screen.dart diff --git a/backend/internal/domain/diary/entity.go b/backend/internal/domain/diary/entity.go index 1d93638..213fccc 100644 --- a/backend/internal/domain/diary/entity.go +++ b/backend/internal/domain/diary/entity.go @@ -17,6 +17,7 @@ type Entry struct { DishID string `json:"dish_id"` RecipeID *string `json:"recipe_id,omitempty"` PortionG *float64 `json:"portion_g,omitempty"` + JobID *string `json:"job_id,omitempty"` CreatedAt time.Time `json:"created_at"` } @@ -30,4 +31,5 @@ type CreateRequest struct { DishID *string `json:"dish_id"` RecipeID *string `json:"recipe_id"` PortionG *float64 `json:"portion_g"` + JobID *string `json:"job_id"` } diff --git a/backend/internal/domain/diary/repository.go b/backend/internal/domain/diary/repository.go index 35df2bf..fd50d8d 100644 --- a/backend/internal/domain/diary/repository.go +++ b/backend/internal/domain/diary/repository.go @@ -30,7 +30,7 @@ func (r *Repository) ListByDate(ctx context.Context, userID, date string) ([]*En rows, err := r.pool.Query(ctx, ` SELECT md.id, md.date::text, md.meal_type, md.portions, - md.source, md.dish_id::text, md.recipe_id::text, md.portion_g, md.created_at, + md.source, md.dish_id::text, md.recipe_id::text, md.portion_g, md.job_id::text, md.created_at, COALESCE(dt.name, d.name) AS dish_name, r.calories_per_serving * md.portions, r.protein_per_serving * md.portions, @@ -72,10 +72,10 @@ func (r *Repository) Create(ctx context.Context, userID string, req CreateReques var entryID string insertError := r.pool.QueryRow(ctx, ` - INSERT INTO meal_diary (user_id, date, meal_type, portions, source, dish_id, recipe_id, portion_g) - VALUES ($1, $2::date, $3, $4, $5, $6, $7, $8) + INSERT INTO meal_diary (user_id, date, meal_type, portions, source, dish_id, recipe_id, portion_g, job_id) + VALUES ($1, $2::date, $3, $4, $5, $6, $7, $8, $9) RETURNING id`, - userID, req.Date, req.MealType, portions, source, req.DishID, req.RecipeID, req.PortionG, + userID, req.Date, req.MealType, portions, source, req.DishID, req.RecipeID, req.PortionG, req.JobID, ).Scan(&entryID) if insertError != nil { return nil, fmt.Errorf("insert diary entry: %w", insertError) @@ -84,7 +84,7 @@ func (r *Repository) Create(ctx context.Context, userID string, req CreateReques row := r.pool.QueryRow(ctx, ` SELECT md.id, md.date::text, md.meal_type, md.portions, - md.source, md.dish_id::text, md.recipe_id::text, md.portion_g, md.created_at, + md.source, md.dish_id::text, md.recipe_id::text, md.portion_g, md.job_id::text, md.created_at, COALESCE(dt.name, d.name) AS dish_name, r.calories_per_serving * md.portions, r.protein_per_serving * md.portions, @@ -121,7 +121,7 @@ func scanEntry(s scannable) (*Entry, error) { var entry Entry scanError := s.Scan( &entry.ID, &entry.Date, &entry.MealType, &entry.Portions, - &entry.Source, &entry.DishID, &entry.RecipeID, &entry.PortionG, &entry.CreatedAt, + &entry.Source, &entry.DishID, &entry.RecipeID, &entry.PortionG, &entry.JobID, &entry.CreatedAt, &entry.Name, &entry.Calories, &entry.ProteinG, &entry.FatG, &entry.CarbsG, ) diff --git a/backend/internal/domain/recognition/handler.go b/backend/internal/domain/recognition/handler.go index 2796bed..4aa4146 100644 --- a/backend/internal/domain/recognition/handler.go +++ b/backend/internal/domain/recognition/handler.go @@ -84,6 +84,14 @@ type imageRequest struct { MimeType string `json:"mime_type"` } +// recognizeDishRequest is the body for POST /ai/recognize-dish. +type recognizeDishRequest struct { + ImageBase64 string `json:"image_base64"` + MimeType string `json:"mime_type"` + TargetDate *string `json:"target_date"` + TargetMealType *string `json:"target_meal_type"` +} + // imagesRequest is the request body for multi-image endpoints. type imagesRequest struct { Images []imageRequest `json:"images"` @@ -173,9 +181,9 @@ func (handler *Handler) RecognizeProducts(responseWriter http.ResponseWriter, re // RecognizeDish handles POST /ai/recognize-dish (async). // Enqueues the image for AI processing and returns 202 Accepted with a job_id. -// Body: {"image_base64": "...", "mime_type": "image/jpeg"} +// Body: {"image_base64": "...", "mime_type": "image/jpeg", "target_date": "2006-01-02", "target_meal_type": "lunch"} func (handler *Handler) RecognizeDish(responseWriter http.ResponseWriter, request *http.Request) { - var req imageRequest + var req recognizeDishRequest if decodeError := json.NewDecoder(request.Body).Decode(&req); decodeError != nil || req.ImageBase64 == "" { writeErrorJSON(responseWriter, http.StatusBadRequest, "image_base64 is required") return @@ -186,11 +194,13 @@ func (handler *Handler) RecognizeDish(responseWriter http.ResponseWriter, reques lang := locale.FromContext(request.Context()) job := &Job{ - UserID: userID, - UserPlan: userPlan, - ImageBase64: req.ImageBase64, - MimeType: req.MimeType, - Lang: lang, + UserID: userID, + UserPlan: userPlan, + ImageBase64: req.ImageBase64, + MimeType: req.MimeType, + Lang: lang, + TargetDate: req.TargetDate, + TargetMealType: req.TargetMealType, } if insertError := handler.jobRepo.InsertJob(request.Context(), job); insertError != nil { slog.Error("insert recognition job", "err", insertError) @@ -221,6 +231,24 @@ func (handler *Handler) RecognizeDish(responseWriter http.ResponseWriter, reques }) } +// ListTodayJobs handles GET /ai/jobs — returns today's unlinked jobs for the current user. +func (handler *Handler) ListTodayJobs(responseWriter http.ResponseWriter, request *http.Request) { + userID := middleware.UserIDFromCtx(request.Context()) + + summaries, listError := handler.jobRepo.ListTodayUnlinked(request.Context(), userID) + if listError != nil { + slog.Error("list today unlinked jobs", "err", listError) + writeErrorJSON(responseWriter, http.StatusInternalServerError, "failed to list jobs") + return + } + + // Return an empty array instead of null when there are no results. + if summaries == nil { + summaries = []*JobSummary{} + } + writeJSON(responseWriter, http.StatusOK, summaries) +} + // GetJobStream handles GET /ai/jobs/{id}/stream — SSE endpoint for job updates. func (handler *Handler) GetJobStream(responseWriter http.ResponseWriter, request *http.Request) { handler.sseBroker.ServeSSE(responseWriter, request) diff --git a/backend/internal/domain/recognition/job.go b/backend/internal/domain/recognition/job.go index 8dc3036..63e69d7 100644 --- a/backend/internal/domain/recognition/job.go +++ b/backend/internal/domain/recognition/job.go @@ -20,18 +20,31 @@ const ( TopicFree = "ai.recognize.free" ) -// Job represents an async dish recognition task stored in recognition_jobs. +// Job represents an async dish recognition task stored in dish_recognition_jobs. type Job struct { - ID string - UserID string - UserPlan string - ImageBase64 string - MimeType string - Lang string - Status string - Result *ai.DishResult - Error *string - CreatedAt time.Time - StartedAt *time.Time - CompletedAt *time.Time + ID string + UserID string + UserPlan string + ImageBase64 string + MimeType string + Lang string + TargetDate *string // nullable YYYY-MM-DD + TargetMealType *string // nullable e.g. "lunch" + Status string + Result *ai.DishResult + Error *string + CreatedAt time.Time + StartedAt *time.Time + CompletedAt *time.Time +} + +// JobSummary is a lightweight job record for list endpoints (omits ImageBase64). +type JobSummary struct { + ID string `json:"id"` + Status string `json:"status"` + TargetDate *string `json:"target_date,omitempty"` + TargetMealType *string `json:"target_meal_type,omitempty"` + Result *ai.DishResult `json:"result,omitempty"` + Error *string `json:"error,omitempty"` + CreatedAt time.Time `json:"created_at"` } diff --git a/backend/internal/domain/recognition/job_repository.go b/backend/internal/domain/recognition/job_repository.go index 0dda270..6b96ac8 100644 --- a/backend/internal/domain/recognition/job_repository.go +++ b/backend/internal/domain/recognition/job_repository.go @@ -9,13 +9,14 @@ import ( "github.com/jackc/pgx/v5/pgxpool" ) -// JobRepository provides all DB operations on recognition_jobs. +// JobRepository provides all DB operations on dish_recognition_jobs. type JobRepository interface { InsertJob(ctx context.Context, job *Job) error GetJobByID(ctx context.Context, jobID string) (*Job, error) UpdateJobStatus(ctx context.Context, jobID, status string, result *ai.DishResult, errMsg *string) error QueuePosition(ctx context.Context, userPlan string, createdAt time.Time) (int, error) NotifyJobUpdate(ctx context.Context, jobID string) error + ListTodayUnlinked(ctx context.Context, userID string) ([]*JobSummary, error) } // PostgresJobRepository implements JobRepository using a pgxpool. @@ -31,10 +32,10 @@ func NewJobRepository(pool *pgxpool.Pool) *PostgresJobRepository { // InsertJob inserts a new recognition job and populates the ID and CreatedAt fields. func (repository *PostgresJobRepository) InsertJob(queryContext context.Context, job *Job) error { return repository.pool.QueryRow(queryContext, - `INSERT INTO recognition_jobs (user_id, user_plan, image_base64, mime_type, lang) - VALUES ($1, $2, $3, $4, $5) + `INSERT INTO dish_recognition_jobs (user_id, user_plan, image_base64, mime_type, lang, target_date, target_meal_type) + VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id, created_at`, - job.UserID, job.UserPlan, job.ImageBase64, job.MimeType, job.Lang, + job.UserID, job.UserPlan, job.ImageBase64, job.MimeType, job.Lang, job.TargetDate, job.TargetMealType, ).Scan(&job.ID, &job.CreatedAt) } @@ -44,13 +45,15 @@ func (repository *PostgresJobRepository) GetJobByID(queryContext context.Context var resultJSON []byte queryError := repository.pool.QueryRow(queryContext, - `SELECT id, user_id, user_plan, image_base64, mime_type, lang, status, + `SELECT id, user_id, user_plan, image_base64, mime_type, lang, + target_date::text, target_meal_type, status, result, error, created_at, started_at, completed_at - FROM recognition_jobs WHERE id = $1`, + FROM dish_recognition_jobs WHERE id = $1`, jobID, ).Scan( &job.ID, &job.UserID, &job.UserPlan, - &job.ImageBase64, &job.MimeType, &job.Lang, &job.Status, + &job.ImageBase64, &job.MimeType, &job.Lang, + &job.TargetDate, &job.TargetMealType, &job.Status, &resultJSON, &job.Error, &job.CreatedAt, &job.StartedAt, &job.CompletedAt, ) if queryError != nil { @@ -86,13 +89,13 @@ func (repository *PostgresJobRepository) UpdateJobStatus( switch status { case JobStatusProcessing: _, updateError := repository.pool.Exec(queryContext, - `UPDATE recognition_jobs SET status = $1, started_at = now() WHERE id = $2`, + `UPDATE dish_recognition_jobs SET status = $1, started_at = now() WHERE id = $2`, status, jobID, ) return updateError default: _, updateError := repository.pool.Exec(queryContext, - `UPDATE recognition_jobs + `UPDATE dish_recognition_jobs SET status = $1, result = $2, error = $3, completed_at = now() WHERE id = $4`, status, resultJSON, errMsg, jobID, @@ -109,7 +112,7 @@ func (repository *PostgresJobRepository) QueuePosition( ) (int, error) { var position int queryError := repository.pool.QueryRow(queryContext, - `SELECT COUNT(*) FROM recognition_jobs + `SELECT COUNT(*) FROM dish_recognition_jobs WHERE status IN ('pending', 'processing') AND user_plan = $1 AND created_at < $2`, @@ -123,3 +126,48 @@ func (repository *PostgresJobRepository) NotifyJobUpdate(queryContext context.Co _, notifyError := repository.pool.Exec(queryContext, `SELECT pg_notify('job_update', $1)`, jobID) return notifyError } + +// ListTodayUnlinked returns today's jobs for the given user that have not yet been +// linked to any meal_diary entry. +func (repository *PostgresJobRepository) ListTodayUnlinked(queryContext context.Context, userID string) ([]*JobSummary, error) { + rows, queryError := repository.pool.Query(queryContext, + `SELECT id, status, target_date::text, target_meal_type, + result, error, created_at + FROM dish_recognition_jobs + WHERE user_id = $1 + AND created_at::date = CURRENT_DATE + AND id NOT IN ( + SELECT job_id FROM meal_diary WHERE job_id IS NOT NULL + ) + ORDER BY created_at DESC`, + userID, + ) + if queryError != nil { + return nil, queryError + } + defer rows.Close() + + var summaries []*JobSummary + for rows.Next() { + var summary JobSummary + var resultJSON []byte + scanError := rows.Scan( + &summary.ID, &summary.Status, &summary.TargetDate, &summary.TargetMealType, + &resultJSON, &summary.Error, &summary.CreatedAt, + ) + if scanError != nil { + return nil, scanError + } + if resultJSON != nil { + var dishResult ai.DishResult + if unmarshalError := json.Unmarshal(resultJSON, &dishResult); unmarshalError == nil { + summary.Result = &dishResult + } + } + summaries = append(summaries, &summary) + } + if rowsError := rows.Err(); rowsError != nil { + return nil, rowsError + } + return summaries, nil +} diff --git a/backend/internal/infra/middleware/cors.go b/backend/internal/infra/middleware/cors.go index bc96af2..9fc4ed2 100644 --- a/backend/internal/infra/middleware/cors.go +++ b/backend/internal/infra/middleware/cors.go @@ -10,7 +10,7 @@ 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"}, + AllowedHeaders: []string{"Authorization", "Content-Type", "X-Request-ID", "Accept-Language", "Cache-Control"}, ExposedHeaders: []string{"X-Request-ID"}, AllowCredentials: true, MaxAge: 300, diff --git a/backend/internal/infra/middleware/logging.go b/backend/internal/infra/middleware/logging.go index fb9bafe..743e434 100644 --- a/backend/internal/infra/middleware/logging.go +++ b/backend/internal/infra/middleware/logging.go @@ -16,6 +16,12 @@ func (rw *responseWriter) WriteHeader(code int) { rw.ResponseWriter.WriteHeader(code) } +func (rw *responseWriter) Flush() { + if flusher, ok := rw.ResponseWriter.(http.Flusher); ok { + flusher.Flush() + } +} + func Logging(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() diff --git a/backend/internal/infra/server/server.go b/backend/internal/infra/server/server.go index 6e53a66..89b2264 100644 --- a/backend/internal/infra/server/server.go +++ b/backend/internal/infra/server/server.go @@ -120,6 +120,7 @@ func NewRouter( r.Post("/recognize-receipt", recognitionHandler.RecognizeReceipt) r.Post("/recognize-products", recognitionHandler.RecognizeProducts) r.Post("/recognize-dish", recognitionHandler.RecognizeDish) + r.Get("/jobs", recognitionHandler.ListTodayJobs) r.Get("/jobs/{id}", recognitionHandler.GetJob) r.Get("/jobs/{id}/stream", recognitionHandler.GetJobStream) r.Post("/generate-menu", menuHandler.GenerateMenu) diff --git a/backend/migrations/001_initial_schema.sql b/backend/migrations/001_initial_schema.sql index 71c82f2..785a81c 100644 --- a/backend/migrations/001_initial_schema.sql +++ b/backend/migrations/001_initial_schema.sql @@ -390,6 +390,31 @@ CREATE TABLE shopping_lists ( UNIQUE (user_id, menu_plan_id) ); +-- --------------------------------------------------------------------------- +-- dish_recognition_jobs +-- --------------------------------------------------------------------------- +CREATE TABLE dish_recognition_jobs ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v7(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + user_plan TEXT NOT NULL, + image_base64 TEXT NOT NULL, + mime_type TEXT NOT NULL DEFAULT 'image/jpeg', + lang TEXT NOT NULL DEFAULT 'en', + target_date DATE, + target_meal_type TEXT, + status TEXT NOT NULL DEFAULT 'pending', + -- pending | processing | done | failed + result JSONB, + error TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + started_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ +); +CREATE INDEX idx_dish_recognition_jobs_user + ON dish_recognition_jobs (user_id, created_at DESC); +CREATE INDEX idx_dish_recognition_jobs_pending + ON dish_recognition_jobs (status, user_plan, created_at ASC); + -- --------------------------------------------------------------------------- -- meal_diary -- --------------------------------------------------------------------------- @@ -400,33 +425,14 @@ CREATE TABLE meal_diary ( meal_type TEXT NOT NULL, portions DECIMAL(5,2) NOT NULL DEFAULT 1, source TEXT NOT NULL DEFAULT 'manual', - dish_id UUID NOT NULL REFERENCES dishes(id) ON DELETE RESTRICT, - recipe_id UUID REFERENCES recipes(id) ON DELETE SET NULL, + dish_id UUID NOT NULL REFERENCES dishes(id) ON DELETE RESTRICT, + recipe_id UUID REFERENCES recipes(id) ON DELETE SET NULL, portion_g DECIMAL(10,2), + job_id UUID REFERENCES dish_recognition_jobs(id) ON DELETE SET NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX idx_meal_diary_user_date ON meal_diary (user_id, date); - --- --------------------------------------------------------------------------- --- recognition_jobs --- --------------------------------------------------------------------------- -CREATE TABLE recognition_jobs ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v7(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - user_plan TEXT NOT NULL, - image_base64 TEXT NOT NULL, - mime_type TEXT NOT NULL DEFAULT 'image/jpeg', - lang TEXT NOT NULL DEFAULT 'en', - status TEXT NOT NULL DEFAULT 'pending', - -- pending | processing | done | failed - result JSONB, - error TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - started_at TIMESTAMPTZ, - completed_at TIMESTAMPTZ -); -CREATE INDEX idx_recognition_jobs_user ON recognition_jobs (user_id, created_at DESC); -CREATE INDEX idx_recognition_jobs_pending ON recognition_jobs (status, user_plan, created_at ASC); +CREATE INDEX idx_meal_diary_job_id ON meal_diary (job_id) WHERE job_id IS NOT NULL; -- --------------------------------------------------------------------------- -- Seed data: languages @@ -605,8 +611,8 @@ INSERT INTO dish_category_translations (category_slug, lang, name) VALUES ('snack', 'ru', 'Снэк'); -- +goose Down -DROP TABLE IF EXISTS recognition_jobs; DROP TABLE IF EXISTS meal_diary; +DROP TABLE IF EXISTS dish_recognition_jobs; DROP TABLE IF EXISTS shopping_lists; DROP TABLE IF EXISTS menu_items; DROP TABLE IF EXISTS menu_plans; diff --git a/client/lib/core/router/app_router.dart b/client/lib/core/router/app_router.dart index 94e8cf4..566a7e4 100644 --- a/client/lib/core/router/app_router.dart +++ b/client/lib/core/router/app_router.dart @@ -20,6 +20,7 @@ import '../../features/recipes/recipe_detail_screen.dart'; import '../../features/recipes/recipes_screen.dart'; import '../../features/profile/profile_screen.dart'; import '../../features/products/product_provider.dart'; +import '../../features/scan/recognition_history_screen.dart'; import '../../shared/models/recipe.dart'; import '../../shared/models/saved_recipe.dart'; @@ -140,6 +141,10 @@ final routerProvider = Provider((ref) { return RecognitionConfirmScreen(items: items); }, ), + GoRoute( + path: '/scan/history', + builder: (_, __) => const RecognitionHistoryScreen(), + ), ShellRoute( builder: (context, state, child) => MainShell(child: child), routes: [ diff --git a/client/lib/core/storage/local_preferences.dart b/client/lib/core/storage/local_preferences.dart new file mode 100644 index 0000000..dc49482 --- /dev/null +++ b/client/lib/core/storage/local_preferences.dart @@ -0,0 +1,17 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +/// Thin wrapper over SharedPreferences for app-level local settings. +class LocalPreferences { + const LocalPreferences(this._prefs); + + final SharedPreferences _prefs; + + static const _keyLastUsedMealType = 'last_used_meal_type'; + + /// Returns the last meal type the user selected when adding to diary, or null. + String? getLastUsedMealType() => _prefs.getString(_keyLastUsedMealType); + + /// Persists the last used meal type. + Future setLastUsedMealType(String mealTypeId) => + _prefs.setString(_keyLastUsedMealType, mealTypeId); +} diff --git a/client/lib/core/storage/local_preferences_provider.dart b/client/lib/core/storage/local_preferences_provider.dart new file mode 100644 index 0000000..9d57a98 --- /dev/null +++ b/client/lib/core/storage/local_preferences_provider.dart @@ -0,0 +1,9 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'local_preferences.dart'; + +/// Provider for [LocalPreferences]. Must be overridden in [ProviderScope] +/// after SharedPreferences is initialised in main(). +final localPreferencesProvider = Provider( + (ref) => throw UnimplementedError('localPreferencesProvider not overridden'), +); diff --git a/client/lib/core/theme/app_theme.dart b/client/lib/core/theme/app_theme.dart index 607f99a..ea6d038 100644 --- a/client/lib/core/theme/app_theme.dart +++ b/client/lib/core/theme/app_theme.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:google_fonts/google_fonts.dart'; import 'app_colors.dart'; ThemeData appTheme() { @@ -23,6 +24,7 @@ ThemeData appTheme() { return ThemeData( useMaterial3: true, colorScheme: base, + fontFamily: GoogleFonts.roboto().fontFamily, scaffoldBackgroundColor: AppColors.background, // ── AppBar ──────────────────────────────────────── diff --git a/client/lib/features/home/home_provider.dart b/client/lib/features/home/home_provider.dart index 93bd3dc..347087f 100644 --- a/client/lib/features/home/home_provider.dart +++ b/client/lib/features/home/home_provider.dart @@ -1,5 +1,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../features/scan/recognition_service.dart'; import '../../shared/models/home_summary.dart'; import 'home_service.dart'; @@ -33,3 +34,25 @@ final homeProvider = StateNotifierProvider>( (ref) => HomeNotifier(ref.read(homeServiceProvider)), ); + +// ── Today's unlinked recognition jobs ───────────────────────── + +class TodayJobsNotifier + extends StateNotifier>> { + final RecognitionService _service; + + TodayJobsNotifier(this._service) : super(const AsyncValue.loading()) { + load(); + } + + Future load() async { + state = const AsyncValue.loading(); + state = + await AsyncValue.guard(() => _service.listTodayUnlinkedJobs()); + } +} + +final todayJobsProvider = + StateNotifierProvider>>( + (ref) => TodayJobsNotifier(ref.read(recognitionServiceProvider)), +); diff --git a/client/lib/features/home/home_screen.dart b/client/lib/features/home/home_screen.dart index a7d6a54..5451375 100644 --- a/client/lib/features/home/home_screen.dart +++ b/client/lib/features/home/home_screen.dart @@ -6,6 +6,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:image_picker/image_picker.dart'; +import '../../core/storage/local_preferences_provider.dart'; import '../../core/theme/app_colors.dart'; import '../../shared/models/diary_entry.dart'; import '../../shared/models/home_summary.dart'; @@ -47,11 +48,14 @@ class HomeScreen extends ConsumerWidget { final expiringSoon = homeSummaryState.valueOrNull?.expiringSoon ?? []; final recommendations = homeSummaryState.valueOrNull?.recommendations ?? []; + final todayJobs = ref.watch(todayJobsProvider).valueOrNull ?? []; + return Scaffold( body: RefreshIndicator( onRefresh: () async { ref.read(homeProvider.notifier).load(); ref.invalidate(diaryProvider(dateString)); + ref.invalidate(todayJobsProvider); }, child: CustomScrollView( slivers: [ @@ -78,6 +82,10 @@ class HomeScreen extends ConsumerWidget { fatG: loggedFat, carbsG: loggedCarbs, ), + if (todayJobs.isNotEmpty) ...[ + const SizedBox(height: 16), + _TodayJobsWidget(jobs: todayJobs), + ], const SizedBox(height: 16), _DailyMealsSection( mealTypeIds: userMealTypes, @@ -777,10 +785,22 @@ Future _pickAndShowDishResult( builder: (_) => _DishProgressDialog(notifier: progressNotifier), ); - // 4. Submit image and listen to SSE stream. + // 4. Determine target date and meal type for context. + final selectedDate = ref.read(selectedDateProvider); + final targetDate = formatDateForDiary(selectedDate); + final localPreferences = ref.read(localPreferencesProvider); + final resolvedMealType = mealTypeId.isNotEmpty + ? mealTypeId + : localPreferences.getLastUsedMealType(); + + // 5. Submit image and listen to SSE stream. final service = ref.read(recognitionServiceProvider); try { - final jobCreated = await service.submitDishRecognition(image); + final jobCreated = await service.submitDishRecognition( + image, + targetDate: targetDate, + targetMealType: resolvedMealType, + ); if (!context.mounted) return; await for (final event in service.streamJobEvents(jobCreated.jobId)) { @@ -803,7 +823,8 @@ Future _pickAndShowDishResult( useSafeArea: true, builder: (sheetContext) => DishResultSheet( dish: event.result, - preselectedMealType: mealTypeId, + preselectedMealType: mealTypeId.isNotEmpty ? mealTypeId : null, + jobId: jobCreated.jobId, onAdded: () => Navigator.pop(sheetContext), ), ); @@ -1065,6 +1086,123 @@ class _ExpiringBanner extends StatelessWidget { } } +// ── Today's recognition jobs widget ─────────────────────────── + +class _TodayJobsWidget extends ConsumerWidget { + final List jobs; + + const _TodayJobsWidget({required this.jobs}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final visibleJobs = jobs.take(3).toList(); + final hasMore = jobs.length > 3; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text('Распознавания', style: theme.textTheme.titleSmall), + const Spacer(), + if (hasMore) + TextButton( + onPressed: () => context.push('/scan/history'), + style: TextButton.styleFrom( + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.symmetric(horizontal: 8), + ), + child: Text( + 'Все', + style: theme.textTheme.labelMedium?.copyWith( + color: theme.colorScheme.primary, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + ...visibleJobs.map((job) => Padding( + padding: const EdgeInsets.only(bottom: 8), + child: _JobTile(job: job), + )), + ], + ); + } +} + +class _JobTile extends ConsumerWidget { + final DishJobSummary job; + + const _JobTile({required this.job}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + + final isDone = job.status == 'done'; + final isFailed = job.status == 'failed'; + final isProcessing = + job.status == 'processing' || job.status == 'pending'; + + final IconData statusIcon; + final Color statusColor; + if (isDone) { + statusIcon = Icons.check_circle_outline; + statusColor = Colors.green; + } else if (isFailed) { + statusIcon = Icons.error_outline; + statusColor = theme.colorScheme.error; + } else { + statusIcon = Icons.hourglass_top_outlined; + statusColor = theme.colorScheme.primary; + } + + final dishName = job.result?.candidates.isNotEmpty == true + ? job.result!.best.dishName + : null; + final subtitle = dishName ?? (isFailed ? (job.error ?? 'Ошибка') : 'Обрабатывается…'); + + return Card( + child: ListTile( + leading: Icon(statusIcon, color: statusColor), + title: Text( + dishName ?? (isProcessing ? 'Распознаётся…' : 'Ошибка'), + style: theme.textTheme.bodyMedium, + ), + subtitle: Text( + [ + if (job.targetMealType != null) job.targetMealType, + if (job.targetDate != null) job.targetDate, + ].join(' · ').isEmpty ? subtitle : [ + if (job.targetMealType != null) job.targetMealType, + if (job.targetDate != null) job.targetDate, + ].join(' · '), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + onTap: isDone && job.result != null + ? () { + showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + builder: (sheetContext) => DishResultSheet( + dish: job.result!, + preselectedMealType: job.targetMealType, + jobId: job.id, + onAdded: () => Navigator.pop(sheetContext), + ), + ); + } + : null, + ), + ); + } +} + // ── Quick actions ───────────────────────────────────────────── class _QuickActionsRow extends StatelessWidget { @@ -1089,6 +1227,14 @@ class _QuickActionsRow extends StatelessWidget { onTap: () => context.push('/menu'), ), ), + const SizedBox(width: 8), + Expanded( + child: _ActionButton( + icon: Icons.history, + label: 'История', + onTap: () => context.push('/scan/history'), + ), + ), ], ); } diff --git a/client/lib/features/scan/dish_result_screen.dart b/client/lib/features/scan/dish_result_screen.dart index a33f906..bcb1760 100644 --- a/client/lib/features/scan/dish_result_screen.dart +++ b/client/lib/features/scan/dish_result_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../core/storage/local_preferences_provider.dart'; import '../../features/menu/menu_provider.dart'; import '../../features/home/home_provider.dart'; import '../../shared/models/meal_type.dart'; @@ -15,11 +16,13 @@ class DishResultSheet extends ConsumerStatefulWidget { required this.dish, required this.onAdded, this.preselectedMealType, + this.jobId, }); final DishResult dish; final VoidCallback onAdded; final String? preselectedMealType; + final String? jobId; @override ConsumerState createState() => _DishResultSheetState(); @@ -107,7 +110,10 @@ class _DishResultSheetState extends ConsumerState { 'portion_g': _portionGrams, 'source': 'recognition', if (_selected.dishId != null) 'dish_id': _selected.dishId, + if (widget.jobId != null) 'job_id': widget.jobId, }); + await ref.read(localPreferencesProvider).setLastUsedMealType(_mealType); + ref.invalidate(todayJobsProvider); if (mounted) widget.onAdded(); } catch (addError) { debugPrint('Add to diary error: $addError'); diff --git a/client/lib/features/scan/recognition_history_screen.dart b/client/lib/features/scan/recognition_history_screen.dart new file mode 100644 index 0000000..71189cc --- /dev/null +++ b/client/lib/features/scan/recognition_history_screen.dart @@ -0,0 +1,129 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../home/home_provider.dart'; +import '../scan/dish_result_screen.dart'; +import 'recognition_service.dart'; + +/// Full-screen page showing all of today's unlinked dish recognition jobs. +class RecognitionHistoryScreen extends ConsumerWidget { + const RecognitionHistoryScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final jobsState = ref.watch(todayJobsProvider); + + return Scaffold( + appBar: AppBar( + title: const Text('История распознавания'), + ), + body: jobsState.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (recognitionError, _) => Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('Не удалось загрузить историю'), + const SizedBox(height: 12), + FilledButton( + onPressed: () => ref.invalidate(todayJobsProvider), + child: const Text('Повторить'), + ), + ], + ), + ), + data: (jobs) { + if (jobs.isEmpty) { + return const Center( + child: Text('Нет распознаваний за сегодня'), + ); + } + return ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: jobs.length, + separatorBuilder: (_, __) => const SizedBox(height: 8), + itemBuilder: (context, index) => + _HistoryJobTile(job: jobs[index]), + ); + }, + ), + ); + } +} + +class _HistoryJobTile extends ConsumerWidget { + final DishJobSummary job; + + const _HistoryJobTile({required this.job}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + + final isDone = job.status == 'done'; + final isFailed = job.status == 'failed'; + final isProcessing = + job.status == 'processing' || job.status == 'pending'; + + final IconData statusIcon; + final Color statusColor; + if (isDone) { + statusIcon = Icons.check_circle_outline; + statusColor = Colors.green; + } else if (isFailed) { + statusIcon = Icons.error_outline; + statusColor = theme.colorScheme.error; + } else { + statusIcon = Icons.hourglass_top_outlined; + statusColor = theme.colorScheme.primary; + } + + final dishName = job.result?.candidates.isNotEmpty == true + ? job.result!.best.dishName + : null; + + final String titleText; + if (isDone) { + titleText = dishName ?? 'Блюдо распознано'; + } else if (isProcessing) { + titleText = 'Распознаётся…'; + } else { + titleText = 'Ошибка распознавания'; + } + + final contextParts = [ + if (job.targetMealType != null) job.targetMealType!, + if (job.targetDate != null) job.targetDate!, + ]; + + return Card( + child: ListTile( + leading: Icon(statusIcon, color: statusColor), + title: Text(titleText, style: theme.textTheme.bodyMedium), + subtitle: contextParts.isNotEmpty + ? Text( + contextParts.join(' · '), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ) + : null, + onTap: isDone && job.result != null + ? () { + showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + builder: (sheetContext) => DishResultSheet( + dish: job.result!, + preselectedMealType: job.targetMealType, + jobId: job.id, + onAdded: () => Navigator.pop(sheetContext), + ), + ); + } + : null, + ), + ); + } +} diff --git a/client/lib/features/scan/recognition_service.dart b/client/lib/features/scan/recognition_service.dart index 3f71484..1b42ae6 100644 --- a/client/lib/features/scan/recognition_service.dart +++ b/client/lib/features/scan/recognition_service.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:dio/dio.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:http/http.dart' as http; import 'package:image_picker/image_picker.dart'; import '../../core/api/api_client.dart'; @@ -144,6 +145,41 @@ class DishResult { // Async job models // --------------------------------------------------------------------------- +/// A lightweight summary of a dish recognition job (no image payload). +class DishJobSummary { + final String id; + final String status; + final String? targetDate; + final String? targetMealType; + final DishResult? result; + final String? error; + final DateTime createdAt; + + const DishJobSummary({ + required this.id, + required this.status, + this.targetDate, + this.targetMealType, + this.result, + this.error, + required this.createdAt, + }); + + factory DishJobSummary.fromJson(Map json) { + return DishJobSummary( + id: json['id'] as String, + status: json['status'] as String? ?? '', + targetDate: json['target_date'] as String?, + targetMealType: json['target_meal_type'] as String?, + result: json['result'] != null + ? DishResult.fromJson(json['result'] as Map) + : null, + error: json['error'] as String?, + createdAt: DateTime.parse(json['created_at'] as String), + ); + } +} + /// The 202 response from POST /ai/recognize-dish. class DishJobCreated { final String jobId; @@ -231,72 +267,87 @@ class RecognitionService { /// Submits a dish image for async recognition. /// Returns a [DishJobCreated] with the job ID and queue position. - Future submitDishRecognition(XFile image) async { - final payload = await _buildImagePayload(image); + Future submitDishRecognition( + XFile image, { + String? targetDate, + String? targetMealType, + }) async { + final imagePayload = await _buildImagePayload(image); + final payload = {...imagePayload}; + if (targetDate != null) payload['target_date'] = targetDate; + if (targetMealType != null) payload['target_meal_type'] = targetMealType; final data = await _client.post('/ai/recognize-dish', data: payload); return DishJobCreated.fromJson(data); } + /// Returns today's recognition jobs that have not yet been linked to a diary entry. + Future> listTodayUnlinkedJobs() async { + final data = await _client.get('/ai/jobs') as List; + return data + .map((element) => + DishJobSummary.fromJson(element as Map)) + .toList(); + } + /// Opens an SSE stream for job [jobId] and emits [DishJobEvent]s until the /// job reaches a terminal state (done or failed) or the stream is cancelled. + /// + /// Uses [http.Client] instead of Dio because on Flutter Web Dio relies on + /// XHR which does not support SSE streaming. [http.BrowserClient] reads the + /// response via XHR onProgress events and delivers chunks before the + /// connection is closed. Stream streamJobEvents(String jobId) async* { final token = await _storage.getAccessToken(); final language = _languageGetter(); - final url = '${_appConfig.apiBaseUrl}/ai/jobs/$jobId/stream'; + final uri = Uri.parse('${_appConfig.apiBaseUrl}/ai/jobs/$jobId/stream'); - final dio = Dio(BaseOptions( - connectTimeout: const Duration(seconds: 30), - receiveTimeout: const Duration(minutes: 5), - )); + final httpClient = http.Client(); + try { + final request = http.Request('GET', uri) + ..headers['Authorization'] = token != null ? 'Bearer $token' : '' + ..headers['Accept'] = 'text/event-stream' + ..headers['Accept-Language'] = language + ..headers['Cache-Control'] = 'no-cache'; - final response = await dio.get( - url, - options: Options( - responseType: ResponseType.stream, - headers: { - 'Authorization': token != null ? 'Bearer $token' : '', - 'Accept': 'text/event-stream', - 'Accept-Language': language, - 'Cache-Control': 'no-cache', - }, - ), - ); + final response = await httpClient.send(request).timeout( + const Duration(seconds: 30), + ); - final stream = response.data!.stream; - final buffer = StringBuffer(); - String? currentEventName; + final buffer = StringBuffer(); + String? currentEventName; - await for (final chunk in stream.map(utf8.decode)) { - buffer.write(chunk); - final text = buffer.toString(); + await for (final chunk in response.stream.map(utf8.decode)) { + buffer.write(chunk); + final text = buffer.toString(); - // Process complete SSE messages (terminated by \n\n). - int doubleNewlineIndex; - var remaining = text; - while ((doubleNewlineIndex = remaining.indexOf('\n\n')) != -1) { - final message = remaining.substring(0, doubleNewlineIndex); - remaining = remaining.substring(doubleNewlineIndex + 2); + // Process complete SSE messages (terminated by \n\n). + int doubleNewlineIndex; + var remaining = text; + while ((doubleNewlineIndex = remaining.indexOf('\n\n')) != -1) { + final message = remaining.substring(0, doubleNewlineIndex); + remaining = remaining.substring(doubleNewlineIndex + 2); - for (final line in message.split('\n')) { - if (line.startsWith('event:')) { - currentEventName = line.substring(6).trim(); - } else if (line.startsWith('data:')) { - final dataPayload = line.substring(5).trim(); - final event = _parseSseEvent(currentEventName, dataPayload); - if (event != null) { - yield event; - if (event is DishJobDone || event is DishJobFailed) { - return; + for (final line in message.split('\n')) { + if (line.startsWith('event:')) { + currentEventName = line.substring(6).trim(); + } else if (line.startsWith('data:')) { + final dataPayload = line.substring(5).trim(); + final event = _parseSseEvent(currentEventName, dataPayload); + if (event != null) { + yield event; + if (event is DishJobDone || event is DishJobFailed) return; } + currentEventName = null; } - currentEventName = null; } } - } - buffer - ..clear() - ..write(remaining); + buffer + ..clear() + ..write(remaining); + } + } finally { + httpClient.close(); } } diff --git a/client/lib/main.dart b/client/lib/main.dart index 14f6c36..802c0e3 100644 --- a/client/lib/main.dart +++ b/client/lib/main.dart @@ -1,8 +1,11 @@ import 'package:firebase_core/firebase_core.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'app.dart'; +import 'core/storage/local_preferences.dart'; +import 'core/storage/local_preferences_provider.dart'; import 'firebase_options.dart'; void main() async { @@ -11,9 +14,16 @@ void main() async { options: DefaultFirebaseOptions.currentPlatform, ); + final sharedPreferences = await SharedPreferences.getInstance(); + runApp( - const ProviderScope( - child: App(), + ProviderScope( + overrides: [ + localPreferencesProvider.overrideWithValue( + LocalPreferences(sharedPreferences), + ), + ], + child: const App(), ), ); } diff --git a/client/pubspec.lock b/client/pubspec.lock index df7fdde..ed85303 100644 --- a/client/pubspec.lock +++ b/client/pubspec.lock @@ -440,6 +440,14 @@ packages: url: "https://pub.dev" source: hosted version: "14.8.1" + google_fonts: + dependency: "direct main" + description: + name: google_fonts + sha256: ba03d03bcaa2f6cb7bd920e3b5027181db75ab524f8891c8bc3aa603885b8055 + url: "https://pub.dev" + source: hosted + version: "6.3.3" google_identity_services_web: dependency: transitive description: @@ -497,7 +505,7 @@ packages: source: hosted version: "2.3.2" http: - dependency: transitive + dependency: "direct main" description: name: http sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" @@ -848,6 +856,62 @@ packages: url: "https://pub.dev" source: hosted version: "0.28.0" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "8374d6200ab33ac99031a852eba4c8eb2170c4bf20778b3e2c9eccb45384fb41" + url: "https://pub.dev" + source: hosted + version: "2.4.21" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" shelf: dependency: transitive description: diff --git a/client/pubspec.yaml b/client/pubspec.yaml index 5e8e9ad..a634a08 100644 --- a/client/pubspec.yaml +++ b/client/pubspec.yaml @@ -20,6 +20,8 @@ dependencies: # Network dio: ^5.4.0 + http: ^1.2.0 + google_fonts: ^6.2.0 # Firebase firebase_core: ^3.0.0 @@ -28,6 +30,7 @@ dependencies: # Storage flutter_secure_storage: ^9.2.0 + shared_preferences: ^2.3.0 # Serialization json_annotation: ^4.9.0