feat: dish recognition job context, diary linkage, home widget, history page
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user