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:
dbastrikin
2026-03-19 16:11:21 +02:00
parent 1aaf20619d
commit cf69a4a3d9
21 changed files with 682 additions and 113 deletions

View File

@@ -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)