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:
@@ -17,6 +17,7 @@ type Entry struct {
|
|||||||
DishID string `json:"dish_id"`
|
DishID string `json:"dish_id"`
|
||||||
RecipeID *string `json:"recipe_id,omitempty"`
|
RecipeID *string `json:"recipe_id,omitempty"`
|
||||||
PortionG *float64 `json:"portion_g,omitempty"`
|
PortionG *float64 `json:"portion_g,omitempty"`
|
||||||
|
JobID *string `json:"job_id,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,4 +31,5 @@ type CreateRequest struct {
|
|||||||
DishID *string `json:"dish_id"`
|
DishID *string `json:"dish_id"`
|
||||||
RecipeID *string `json:"recipe_id"`
|
RecipeID *string `json:"recipe_id"`
|
||||||
PortionG *float64 `json:"portion_g"`
|
PortionG *float64 `json:"portion_g"`
|
||||||
|
JobID *string `json:"job_id"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ func (r *Repository) ListByDate(ctx context.Context, userID, date string) ([]*En
|
|||||||
rows, err := r.pool.Query(ctx, `
|
rows, err := r.pool.Query(ctx, `
|
||||||
SELECT
|
SELECT
|
||||||
md.id, md.date::text, md.meal_type, md.portions,
|
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,
|
COALESCE(dt.name, d.name) AS dish_name,
|
||||||
r.calories_per_serving * md.portions,
|
r.calories_per_serving * md.portions,
|
||||||
r.protein_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
|
var entryID string
|
||||||
insertError := r.pool.QueryRow(ctx, `
|
insertError := r.pool.QueryRow(ctx, `
|
||||||
INSERT INTO meal_diary (user_id, date, meal_type, portions, source, dish_id, recipe_id, portion_g)
|
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)
|
VALUES ($1, $2::date, $3, $4, $5, $6, $7, $8, $9)
|
||||||
RETURNING id`,
|
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)
|
).Scan(&entryID)
|
||||||
if insertError != nil {
|
if insertError != nil {
|
||||||
return nil, fmt.Errorf("insert diary entry: %w", insertError)
|
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, `
|
row := r.pool.QueryRow(ctx, `
|
||||||
SELECT
|
SELECT
|
||||||
md.id, md.date::text, md.meal_type, md.portions,
|
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,
|
COALESCE(dt.name, d.name) AS dish_name,
|
||||||
r.calories_per_serving * md.portions,
|
r.calories_per_serving * md.portions,
|
||||||
r.protein_per_serving * md.portions,
|
r.protein_per_serving * md.portions,
|
||||||
@@ -121,7 +121,7 @@ func scanEntry(s scannable) (*Entry, error) {
|
|||||||
var entry Entry
|
var entry Entry
|
||||||
scanError := s.Scan(
|
scanError := s.Scan(
|
||||||
&entry.ID, &entry.Date, &entry.MealType, &entry.Portions,
|
&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.Name,
|
||||||
&entry.Calories, &entry.ProteinG, &entry.FatG, &entry.CarbsG,
|
&entry.Calories, &entry.ProteinG, &entry.FatG, &entry.CarbsG,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -84,6 +84,14 @@ type imageRequest struct {
|
|||||||
MimeType string `json:"mime_type"`
|
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.
|
// imagesRequest is the request body for multi-image endpoints.
|
||||||
type imagesRequest struct {
|
type imagesRequest struct {
|
||||||
Images []imageRequest `json:"images"`
|
Images []imageRequest `json:"images"`
|
||||||
@@ -173,9 +181,9 @@ func (handler *Handler) RecognizeProducts(responseWriter http.ResponseWriter, re
|
|||||||
|
|
||||||
// RecognizeDish handles POST /ai/recognize-dish (async).
|
// RecognizeDish handles POST /ai/recognize-dish (async).
|
||||||
// Enqueues the image for AI processing and returns 202 Accepted with a job_id.
|
// 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) {
|
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 == "" {
|
if decodeError := json.NewDecoder(request.Body).Decode(&req); decodeError != nil || req.ImageBase64 == "" {
|
||||||
writeErrorJSON(responseWriter, http.StatusBadRequest, "image_base64 is required")
|
writeErrorJSON(responseWriter, http.StatusBadRequest, "image_base64 is required")
|
||||||
return
|
return
|
||||||
@@ -186,11 +194,13 @@ func (handler *Handler) RecognizeDish(responseWriter http.ResponseWriter, reques
|
|||||||
lang := locale.FromContext(request.Context())
|
lang := locale.FromContext(request.Context())
|
||||||
|
|
||||||
job := &Job{
|
job := &Job{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
UserPlan: userPlan,
|
UserPlan: userPlan,
|
||||||
ImageBase64: req.ImageBase64,
|
ImageBase64: req.ImageBase64,
|
||||||
MimeType: req.MimeType,
|
MimeType: req.MimeType,
|
||||||
Lang: lang,
|
Lang: lang,
|
||||||
|
TargetDate: req.TargetDate,
|
||||||
|
TargetMealType: req.TargetMealType,
|
||||||
}
|
}
|
||||||
if insertError := handler.jobRepo.InsertJob(request.Context(), job); insertError != nil {
|
if insertError := handler.jobRepo.InsertJob(request.Context(), job); insertError != nil {
|
||||||
slog.Error("insert recognition job", "err", insertError)
|
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.
|
// GetJobStream handles GET /ai/jobs/{id}/stream — SSE endpoint for job updates.
|
||||||
func (handler *Handler) GetJobStream(responseWriter http.ResponseWriter, request *http.Request) {
|
func (handler *Handler) GetJobStream(responseWriter http.ResponseWriter, request *http.Request) {
|
||||||
handler.sseBroker.ServeSSE(responseWriter, request)
|
handler.sseBroker.ServeSSE(responseWriter, request)
|
||||||
|
|||||||
@@ -20,18 +20,31 @@ const (
|
|||||||
TopicFree = "ai.recognize.free"
|
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 {
|
type Job struct {
|
||||||
ID string
|
ID string
|
||||||
UserID string
|
UserID string
|
||||||
UserPlan string
|
UserPlan string
|
||||||
ImageBase64 string
|
ImageBase64 string
|
||||||
MimeType string
|
MimeType string
|
||||||
Lang string
|
Lang string
|
||||||
Status string
|
TargetDate *string // nullable YYYY-MM-DD
|
||||||
Result *ai.DishResult
|
TargetMealType *string // nullable e.g. "lunch"
|
||||||
Error *string
|
Status string
|
||||||
CreatedAt time.Time
|
Result *ai.DishResult
|
||||||
StartedAt *time.Time
|
Error *string
|
||||||
CompletedAt *time.Time
|
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"
|
"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 {
|
type JobRepository interface {
|
||||||
InsertJob(ctx context.Context, job *Job) error
|
InsertJob(ctx context.Context, job *Job) error
|
||||||
GetJobByID(ctx context.Context, jobID string) (*Job, error)
|
GetJobByID(ctx context.Context, jobID string) (*Job, error)
|
||||||
UpdateJobStatus(ctx context.Context, jobID, status string, result *ai.DishResult, errMsg *string) 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)
|
QueuePosition(ctx context.Context, userPlan string, createdAt time.Time) (int, error)
|
||||||
NotifyJobUpdate(ctx context.Context, jobID string) error
|
NotifyJobUpdate(ctx context.Context, jobID string) error
|
||||||
|
ListTodayUnlinked(ctx context.Context, userID string) ([]*JobSummary, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// PostgresJobRepository implements JobRepository using a pgxpool.
|
// 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.
|
// InsertJob inserts a new recognition job and populates the ID and CreatedAt fields.
|
||||||
func (repository *PostgresJobRepository) InsertJob(queryContext context.Context, job *Job) error {
|
func (repository *PostgresJobRepository) InsertJob(queryContext context.Context, job *Job) error {
|
||||||
return repository.pool.QueryRow(queryContext,
|
return repository.pool.QueryRow(queryContext,
|
||||||
`INSERT INTO recognition_jobs (user_id, user_plan, image_base64, mime_type, lang)
|
`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)
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
RETURNING id, created_at`,
|
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)
|
).Scan(&job.ID, &job.CreatedAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,13 +45,15 @@ func (repository *PostgresJobRepository) GetJobByID(queryContext context.Context
|
|||||||
var resultJSON []byte
|
var resultJSON []byte
|
||||||
|
|
||||||
queryError := repository.pool.QueryRow(queryContext,
|
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
|
result, error, created_at, started_at, completed_at
|
||||||
FROM recognition_jobs WHERE id = $1`,
|
FROM dish_recognition_jobs WHERE id = $1`,
|
||||||
jobID,
|
jobID,
|
||||||
).Scan(
|
).Scan(
|
||||||
&job.ID, &job.UserID, &job.UserPlan,
|
&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,
|
&resultJSON, &job.Error, &job.CreatedAt, &job.StartedAt, &job.CompletedAt,
|
||||||
)
|
)
|
||||||
if queryError != nil {
|
if queryError != nil {
|
||||||
@@ -86,13 +89,13 @@ func (repository *PostgresJobRepository) UpdateJobStatus(
|
|||||||
switch status {
|
switch status {
|
||||||
case JobStatusProcessing:
|
case JobStatusProcessing:
|
||||||
_, updateError := repository.pool.Exec(queryContext,
|
_, 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,
|
status, jobID,
|
||||||
)
|
)
|
||||||
return updateError
|
return updateError
|
||||||
default:
|
default:
|
||||||
_, updateError := repository.pool.Exec(queryContext,
|
_, updateError := repository.pool.Exec(queryContext,
|
||||||
`UPDATE recognition_jobs
|
`UPDATE dish_recognition_jobs
|
||||||
SET status = $1, result = $2, error = $3, completed_at = now()
|
SET status = $1, result = $2, error = $3, completed_at = now()
|
||||||
WHERE id = $4`,
|
WHERE id = $4`,
|
||||||
status, resultJSON, errMsg, jobID,
|
status, resultJSON, errMsg, jobID,
|
||||||
@@ -109,7 +112,7 @@ func (repository *PostgresJobRepository) QueuePosition(
|
|||||||
) (int, error) {
|
) (int, error) {
|
||||||
var position int
|
var position int
|
||||||
queryError := repository.pool.QueryRow(queryContext,
|
queryError := repository.pool.QueryRow(queryContext,
|
||||||
`SELECT COUNT(*) FROM recognition_jobs
|
`SELECT COUNT(*) FROM dish_recognition_jobs
|
||||||
WHERE status IN ('pending', 'processing')
|
WHERE status IN ('pending', 'processing')
|
||||||
AND user_plan = $1
|
AND user_plan = $1
|
||||||
AND created_at < $2`,
|
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)
|
_, notifyError := repository.pool.Exec(queryContext, `SELECT pg_notify('job_update', $1)`, jobID)
|
||||||
return notifyError
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ func CORS(allowedOrigins []string) func(http.Handler) http.Handler {
|
|||||||
return cors.Handler(cors.Options{
|
return cors.Handler(cors.Options{
|
||||||
AllowedOrigins: allowedOrigins,
|
AllowedOrigins: allowedOrigins,
|
||||||
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
|
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"},
|
ExposedHeaders: []string{"X-Request-ID"},
|
||||||
AllowCredentials: true,
|
AllowCredentials: true,
|
||||||
MaxAge: 300,
|
MaxAge: 300,
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ func (rw *responseWriter) WriteHeader(code int) {
|
|||||||
rw.ResponseWriter.WriteHeader(code)
|
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 {
|
func Logging(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ func NewRouter(
|
|||||||
r.Post("/recognize-receipt", recognitionHandler.RecognizeReceipt)
|
r.Post("/recognize-receipt", recognitionHandler.RecognizeReceipt)
|
||||||
r.Post("/recognize-products", recognitionHandler.RecognizeProducts)
|
r.Post("/recognize-products", recognitionHandler.RecognizeProducts)
|
||||||
r.Post("/recognize-dish", recognitionHandler.RecognizeDish)
|
r.Post("/recognize-dish", recognitionHandler.RecognizeDish)
|
||||||
|
r.Get("/jobs", recognitionHandler.ListTodayJobs)
|
||||||
r.Get("/jobs/{id}", recognitionHandler.GetJob)
|
r.Get("/jobs/{id}", recognitionHandler.GetJob)
|
||||||
r.Get("/jobs/{id}/stream", recognitionHandler.GetJobStream)
|
r.Get("/jobs/{id}/stream", recognitionHandler.GetJobStream)
|
||||||
r.Post("/generate-menu", menuHandler.GenerateMenu)
|
r.Post("/generate-menu", menuHandler.GenerateMenu)
|
||||||
|
|||||||
@@ -390,6 +390,31 @@ CREATE TABLE shopping_lists (
|
|||||||
UNIQUE (user_id, menu_plan_id)
|
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
|
-- meal_diary
|
||||||
-- ---------------------------------------------------------------------------
|
-- ---------------------------------------------------------------------------
|
||||||
@@ -400,33 +425,14 @@ CREATE TABLE meal_diary (
|
|||||||
meal_type TEXT NOT NULL,
|
meal_type TEXT NOT NULL,
|
||||||
portions DECIMAL(5,2) NOT NULL DEFAULT 1,
|
portions DECIMAL(5,2) NOT NULL DEFAULT 1,
|
||||||
source TEXT NOT NULL DEFAULT 'manual',
|
source TEXT NOT NULL DEFAULT 'manual',
|
||||||
dish_id UUID NOT NULL REFERENCES dishes(id) ON DELETE RESTRICT,
|
dish_id UUID NOT NULL REFERENCES dishes(id) ON DELETE RESTRICT,
|
||||||
recipe_id UUID REFERENCES recipes(id) ON DELETE SET NULL,
|
recipe_id UUID REFERENCES recipes(id) ON DELETE SET NULL,
|
||||||
portion_g DECIMAL(10,2),
|
portion_g DECIMAL(10,2),
|
||||||
|
job_id UUID REFERENCES dish_recognition_jobs(id) ON DELETE SET NULL,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
);
|
);
|
||||||
CREATE INDEX idx_meal_diary_user_date ON meal_diary (user_id, date);
|
CREATE INDEX idx_meal_diary_user_date ON meal_diary (user_id, date);
|
||||||
|
CREATE INDEX idx_meal_diary_job_id ON meal_diary (job_id) WHERE job_id IS NOT NULL;
|
||||||
-- ---------------------------------------------------------------------------
|
|
||||||
-- 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);
|
|
||||||
|
|
||||||
-- ---------------------------------------------------------------------------
|
-- ---------------------------------------------------------------------------
|
||||||
-- Seed data: languages
|
-- Seed data: languages
|
||||||
@@ -605,8 +611,8 @@ INSERT INTO dish_category_translations (category_slug, lang, name) VALUES
|
|||||||
('snack', 'ru', 'Снэк');
|
('snack', 'ru', 'Снэк');
|
||||||
|
|
||||||
-- +goose Down
|
-- +goose Down
|
||||||
DROP TABLE IF EXISTS recognition_jobs;
|
|
||||||
DROP TABLE IF EXISTS meal_diary;
|
DROP TABLE IF EXISTS meal_diary;
|
||||||
|
DROP TABLE IF EXISTS dish_recognition_jobs;
|
||||||
DROP TABLE IF EXISTS shopping_lists;
|
DROP TABLE IF EXISTS shopping_lists;
|
||||||
DROP TABLE IF EXISTS menu_items;
|
DROP TABLE IF EXISTS menu_items;
|
||||||
DROP TABLE IF EXISTS menu_plans;
|
DROP TABLE IF EXISTS menu_plans;
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import '../../features/recipes/recipe_detail_screen.dart';
|
|||||||
import '../../features/recipes/recipes_screen.dart';
|
import '../../features/recipes/recipes_screen.dart';
|
||||||
import '../../features/profile/profile_screen.dart';
|
import '../../features/profile/profile_screen.dart';
|
||||||
import '../../features/products/product_provider.dart';
|
import '../../features/products/product_provider.dart';
|
||||||
|
import '../../features/scan/recognition_history_screen.dart';
|
||||||
import '../../shared/models/recipe.dart';
|
import '../../shared/models/recipe.dart';
|
||||||
import '../../shared/models/saved_recipe.dart';
|
import '../../shared/models/saved_recipe.dart';
|
||||||
|
|
||||||
@@ -140,6 +141,10 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
return RecognitionConfirmScreen(items: items);
|
return RecognitionConfirmScreen(items: items);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/scan/history',
|
||||||
|
builder: (_, __) => const RecognitionHistoryScreen(),
|
||||||
|
),
|
||||||
ShellRoute(
|
ShellRoute(
|
||||||
builder: (context, state, child) => MainShell(child: child),
|
builder: (context, state, child) => MainShell(child: child),
|
||||||
routes: [
|
routes: [
|
||||||
|
|||||||
17
client/lib/core/storage/local_preferences.dart
Normal file
17
client/lib/core/storage/local_preferences.dart
Normal file
@@ -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<void> setLastUsedMealType(String mealTypeId) =>
|
||||||
|
_prefs.setString(_keyLastUsedMealType, mealTypeId);
|
||||||
|
}
|
||||||
9
client/lib/core/storage/local_preferences_provider.dart
Normal file
9
client/lib/core/storage/local_preferences_provider.dart
Normal file
@@ -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<LocalPreferences>(
|
||||||
|
(ref) => throw UnimplementedError('localPreferencesProvider not overridden'),
|
||||||
|
);
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
import 'app_colors.dart';
|
import 'app_colors.dart';
|
||||||
|
|
||||||
ThemeData appTheme() {
|
ThemeData appTheme() {
|
||||||
@@ -23,6 +24,7 @@ ThemeData appTheme() {
|
|||||||
return ThemeData(
|
return ThemeData(
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
colorScheme: base,
|
colorScheme: base,
|
||||||
|
fontFamily: GoogleFonts.roboto().fontFamily,
|
||||||
scaffoldBackgroundColor: AppColors.background,
|
scaffoldBackgroundColor: AppColors.background,
|
||||||
|
|
||||||
// ── AppBar ────────────────────────────────────────
|
// ── AppBar ────────────────────────────────────────
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../features/scan/recognition_service.dart';
|
||||||
import '../../shared/models/home_summary.dart';
|
import '../../shared/models/home_summary.dart';
|
||||||
import 'home_service.dart';
|
import 'home_service.dart';
|
||||||
|
|
||||||
@@ -33,3 +34,25 @@ final homeProvider =
|
|||||||
StateNotifierProvider<HomeNotifier, AsyncValue<HomeSummary>>(
|
StateNotifierProvider<HomeNotifier, AsyncValue<HomeSummary>>(
|
||||||
(ref) => HomeNotifier(ref.read(homeServiceProvider)),
|
(ref) => HomeNotifier(ref.read(homeServiceProvider)),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ── Today's unlinked recognition jobs ─────────────────────────
|
||||||
|
|
||||||
|
class TodayJobsNotifier
|
||||||
|
extends StateNotifier<AsyncValue<List<DishJobSummary>>> {
|
||||||
|
final RecognitionService _service;
|
||||||
|
|
||||||
|
TodayJobsNotifier(this._service) : super(const AsyncValue.loading()) {
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> load() async {
|
||||||
|
state = const AsyncValue.loading();
|
||||||
|
state =
|
||||||
|
await AsyncValue.guard(() => _service.listTodayUnlinkedJobs());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final todayJobsProvider =
|
||||||
|
StateNotifierProvider<TodayJobsNotifier, AsyncValue<List<DishJobSummary>>>(
|
||||||
|
(ref) => TodayJobsNotifier(ref.read(recognitionServiceProvider)),
|
||||||
|
);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:image_picker/image_picker.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
|
||||||
|
import '../../core/storage/local_preferences_provider.dart';
|
||||||
import '../../core/theme/app_colors.dart';
|
import '../../core/theme/app_colors.dart';
|
||||||
import '../../shared/models/diary_entry.dart';
|
import '../../shared/models/diary_entry.dart';
|
||||||
import '../../shared/models/home_summary.dart';
|
import '../../shared/models/home_summary.dart';
|
||||||
@@ -47,11 +48,14 @@ class HomeScreen extends ConsumerWidget {
|
|||||||
final expiringSoon = homeSummaryState.valueOrNull?.expiringSoon ?? [];
|
final expiringSoon = homeSummaryState.valueOrNull?.expiringSoon ?? [];
|
||||||
final recommendations = homeSummaryState.valueOrNull?.recommendations ?? [];
|
final recommendations = homeSummaryState.valueOrNull?.recommendations ?? [];
|
||||||
|
|
||||||
|
final todayJobs = ref.watch(todayJobsProvider).valueOrNull ?? [];
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: RefreshIndicator(
|
body: RefreshIndicator(
|
||||||
onRefresh: () async {
|
onRefresh: () async {
|
||||||
ref.read(homeProvider.notifier).load();
|
ref.read(homeProvider.notifier).load();
|
||||||
ref.invalidate(diaryProvider(dateString));
|
ref.invalidate(diaryProvider(dateString));
|
||||||
|
ref.invalidate(todayJobsProvider);
|
||||||
},
|
},
|
||||||
child: CustomScrollView(
|
child: CustomScrollView(
|
||||||
slivers: [
|
slivers: [
|
||||||
@@ -78,6 +82,10 @@ class HomeScreen extends ConsumerWidget {
|
|||||||
fatG: loggedFat,
|
fatG: loggedFat,
|
||||||
carbsG: loggedCarbs,
|
carbsG: loggedCarbs,
|
||||||
),
|
),
|
||||||
|
if (todayJobs.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_TodayJobsWidget(jobs: todayJobs),
|
||||||
|
],
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_DailyMealsSection(
|
_DailyMealsSection(
|
||||||
mealTypeIds: userMealTypes,
|
mealTypeIds: userMealTypes,
|
||||||
@@ -777,10 +785,22 @@ Future<void> _pickAndShowDishResult(
|
|||||||
builder: (_) => _DishProgressDialog(notifier: progressNotifier),
|
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);
|
final service = ref.read(recognitionServiceProvider);
|
||||||
try {
|
try {
|
||||||
final jobCreated = await service.submitDishRecognition(image);
|
final jobCreated = await service.submitDishRecognition(
|
||||||
|
image,
|
||||||
|
targetDate: targetDate,
|
||||||
|
targetMealType: resolvedMealType,
|
||||||
|
);
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
|
|
||||||
await for (final event in service.streamJobEvents(jobCreated.jobId)) {
|
await for (final event in service.streamJobEvents(jobCreated.jobId)) {
|
||||||
@@ -803,7 +823,8 @@ Future<void> _pickAndShowDishResult(
|
|||||||
useSafeArea: true,
|
useSafeArea: true,
|
||||||
builder: (sheetContext) => DishResultSheet(
|
builder: (sheetContext) => DishResultSheet(
|
||||||
dish: event.result,
|
dish: event.result,
|
||||||
preselectedMealType: mealTypeId,
|
preselectedMealType: mealTypeId.isNotEmpty ? mealTypeId : null,
|
||||||
|
jobId: jobCreated.jobId,
|
||||||
onAdded: () => Navigator.pop(sheetContext),
|
onAdded: () => Navigator.pop(sheetContext),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -1065,6 +1086,123 @@ class _ExpiringBanner extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Today's recognition jobs widget ───────────────────────────
|
||||||
|
|
||||||
|
class _TodayJobsWidget extends ConsumerWidget {
|
||||||
|
final List<DishJobSummary> 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 ─────────────────────────────────────────────
|
// ── Quick actions ─────────────────────────────────────────────
|
||||||
|
|
||||||
class _QuickActionsRow extends StatelessWidget {
|
class _QuickActionsRow extends StatelessWidget {
|
||||||
@@ -1089,6 +1227,14 @@ class _QuickActionsRow extends StatelessWidget {
|
|||||||
onTap: () => context.push('/menu'),
|
onTap: () => context.push('/menu'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: _ActionButton(
|
||||||
|
icon: Icons.history,
|
||||||
|
label: 'История',
|
||||||
|
onTap: () => context.push('/scan/history'),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../core/storage/local_preferences_provider.dart';
|
||||||
import '../../features/menu/menu_provider.dart';
|
import '../../features/menu/menu_provider.dart';
|
||||||
import '../../features/home/home_provider.dart';
|
import '../../features/home/home_provider.dart';
|
||||||
import '../../shared/models/meal_type.dart';
|
import '../../shared/models/meal_type.dart';
|
||||||
@@ -15,11 +16,13 @@ class DishResultSheet extends ConsumerStatefulWidget {
|
|||||||
required this.dish,
|
required this.dish,
|
||||||
required this.onAdded,
|
required this.onAdded,
|
||||||
this.preselectedMealType,
|
this.preselectedMealType,
|
||||||
|
this.jobId,
|
||||||
});
|
});
|
||||||
|
|
||||||
final DishResult dish;
|
final DishResult dish;
|
||||||
final VoidCallback onAdded;
|
final VoidCallback onAdded;
|
||||||
final String? preselectedMealType;
|
final String? preselectedMealType;
|
||||||
|
final String? jobId;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ConsumerState<DishResultSheet> createState() => _DishResultSheetState();
|
ConsumerState<DishResultSheet> createState() => _DishResultSheetState();
|
||||||
@@ -107,7 +110,10 @@ class _DishResultSheetState extends ConsumerState<DishResultSheet> {
|
|||||||
'portion_g': _portionGrams,
|
'portion_g': _portionGrams,
|
||||||
'source': 'recognition',
|
'source': 'recognition',
|
||||||
if (_selected.dishId != null) 'dish_id': _selected.dishId,
|
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();
|
if (mounted) widget.onAdded();
|
||||||
} catch (addError) {
|
} catch (addError) {
|
||||||
debugPrint('Add to diary error: $addError');
|
debugPrint('Add to diary error: $addError');
|
||||||
|
|||||||
129
client/lib/features/scan/recognition_history_screen.dart
Normal file
129
client/lib/features/scan/recognition_history_screen.dart
Normal file
@@ -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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import 'dart:convert';
|
|||||||
|
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:image_picker/image_picker.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
|
||||||
import '../../core/api/api_client.dart';
|
import '../../core/api/api_client.dart';
|
||||||
@@ -144,6 +145,41 @@ class DishResult {
|
|||||||
// Async job models
|
// 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<String, dynamic> 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<String, dynamic>)
|
||||||
|
: null,
|
||||||
|
error: json['error'] as String?,
|
||||||
|
createdAt: DateTime.parse(json['created_at'] as String),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The 202 response from POST /ai/recognize-dish.
|
/// The 202 response from POST /ai/recognize-dish.
|
||||||
class DishJobCreated {
|
class DishJobCreated {
|
||||||
final String jobId;
|
final String jobId;
|
||||||
@@ -231,72 +267,87 @@ class RecognitionService {
|
|||||||
|
|
||||||
/// Submits a dish image for async recognition.
|
/// Submits a dish image for async recognition.
|
||||||
/// Returns a [DishJobCreated] with the job ID and queue position.
|
/// Returns a [DishJobCreated] with the job ID and queue position.
|
||||||
Future<DishJobCreated> submitDishRecognition(XFile image) async {
|
Future<DishJobCreated> submitDishRecognition(
|
||||||
final payload = await _buildImagePayload(image);
|
XFile image, {
|
||||||
|
String? targetDate,
|
||||||
|
String? targetMealType,
|
||||||
|
}) async {
|
||||||
|
final imagePayload = await _buildImagePayload(image);
|
||||||
|
final payload = <String, dynamic>{...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);
|
final data = await _client.post('/ai/recognize-dish', data: payload);
|
||||||
return DishJobCreated.fromJson(data);
|
return DishJobCreated.fromJson(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns today's recognition jobs that have not yet been linked to a diary entry.
|
||||||
|
Future<List<DishJobSummary>> listTodayUnlinkedJobs() async {
|
||||||
|
final data = await _client.get('/ai/jobs') as List<dynamic>;
|
||||||
|
return data
|
||||||
|
.map((element) =>
|
||||||
|
DishJobSummary.fromJson(element as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
/// Opens an SSE stream for job [jobId] and emits [DishJobEvent]s until the
|
/// 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.
|
/// 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<DishJobEvent> streamJobEvents(String jobId) async* {
|
Stream<DishJobEvent> streamJobEvents(String jobId) async* {
|
||||||
final token = await _storage.getAccessToken();
|
final token = await _storage.getAccessToken();
|
||||||
final language = _languageGetter();
|
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(
|
final httpClient = http.Client();
|
||||||
connectTimeout: const Duration(seconds: 30),
|
try {
|
||||||
receiveTimeout: const Duration(minutes: 5),
|
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<ResponseBody>(
|
final response = await httpClient.send(request).timeout(
|
||||||
url,
|
const Duration(seconds: 30),
|
||||||
options: Options(
|
);
|
||||||
responseType: ResponseType.stream,
|
|
||||||
headers: {
|
|
||||||
'Authorization': token != null ? 'Bearer $token' : '',
|
|
||||||
'Accept': 'text/event-stream',
|
|
||||||
'Accept-Language': language,
|
|
||||||
'Cache-Control': 'no-cache',
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
final stream = response.data!.stream;
|
final buffer = StringBuffer();
|
||||||
final buffer = StringBuffer();
|
String? currentEventName;
|
||||||
String? currentEventName;
|
|
||||||
|
|
||||||
await for (final chunk in stream.map(utf8.decode)) {
|
await for (final chunk in response.stream.map(utf8.decode)) {
|
||||||
buffer.write(chunk);
|
buffer.write(chunk);
|
||||||
final text = buffer.toString();
|
final text = buffer.toString();
|
||||||
|
|
||||||
// Process complete SSE messages (terminated by \n\n).
|
// Process complete SSE messages (terminated by \n\n).
|
||||||
int doubleNewlineIndex;
|
int doubleNewlineIndex;
|
||||||
var remaining = text;
|
var remaining = text;
|
||||||
while ((doubleNewlineIndex = remaining.indexOf('\n\n')) != -1) {
|
while ((doubleNewlineIndex = remaining.indexOf('\n\n')) != -1) {
|
||||||
final message = remaining.substring(0, doubleNewlineIndex);
|
final message = remaining.substring(0, doubleNewlineIndex);
|
||||||
remaining = remaining.substring(doubleNewlineIndex + 2);
|
remaining = remaining.substring(doubleNewlineIndex + 2);
|
||||||
|
|
||||||
for (final line in message.split('\n')) {
|
for (final line in message.split('\n')) {
|
||||||
if (line.startsWith('event:')) {
|
if (line.startsWith('event:')) {
|
||||||
currentEventName = line.substring(6).trim();
|
currentEventName = line.substring(6).trim();
|
||||||
} else if (line.startsWith('data:')) {
|
} else if (line.startsWith('data:')) {
|
||||||
final dataPayload = line.substring(5).trim();
|
final dataPayload = line.substring(5).trim();
|
||||||
final event = _parseSseEvent(currentEventName, dataPayload);
|
final event = _parseSseEvent(currentEventName, dataPayload);
|
||||||
if (event != null) {
|
if (event != null) {
|
||||||
yield event;
|
yield event;
|
||||||
if (event is DishJobDone || event is DishJobFailed) {
|
if (event is DishJobDone || event is DishJobFailed) return;
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
currentEventName = null;
|
||||||
}
|
}
|
||||||
currentEventName = null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
buffer
|
buffer
|
||||||
..clear()
|
..clear()
|
||||||
..write(remaining);
|
..write(remaining);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
httpClient.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import 'package:firebase_core/firebase_core.dart';
|
import 'package:firebase_core/firebase_core.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
import 'app.dart';
|
import 'app.dart';
|
||||||
|
import 'core/storage/local_preferences.dart';
|
||||||
|
import 'core/storage/local_preferences_provider.dart';
|
||||||
import 'firebase_options.dart';
|
import 'firebase_options.dart';
|
||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
@@ -11,9 +14,16 @@ void main() async {
|
|||||||
options: DefaultFirebaseOptions.currentPlatform,
|
options: DefaultFirebaseOptions.currentPlatform,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final sharedPreferences = await SharedPreferences.getInstance();
|
||||||
|
|
||||||
runApp(
|
runApp(
|
||||||
const ProviderScope(
|
ProviderScope(
|
||||||
child: App(),
|
overrides: [
|
||||||
|
localPreferencesProvider.overrideWithValue(
|
||||||
|
LocalPreferences(sharedPreferences),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
child: const App(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -440,6 +440,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "14.8.1"
|
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:
|
google_identity_services_web:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -497,7 +505,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "2.3.2"
|
version: "2.3.2"
|
||||||
http:
|
http:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: http
|
name: http
|
||||||
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||||
@@ -848,6 +856,62 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.28.0"
|
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:
|
shelf:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ dependencies:
|
|||||||
|
|
||||||
# Network
|
# Network
|
||||||
dio: ^5.4.0
|
dio: ^5.4.0
|
||||||
|
http: ^1.2.0
|
||||||
|
google_fonts: ^6.2.0
|
||||||
|
|
||||||
# Firebase
|
# Firebase
|
||||||
firebase_core: ^3.0.0
|
firebase_core: ^3.0.0
|
||||||
@@ -28,6 +30,7 @@ dependencies:
|
|||||||
|
|
||||||
# Storage
|
# Storage
|
||||||
flutter_secure_storage: ^9.2.0
|
flutter_secure_storage: ^9.2.0
|
||||||
|
shared_preferences: ^2.3.0
|
||||||
|
|
||||||
# Serialization
|
# Serialization
|
||||||
json_annotation: ^4.9.0
|
json_annotation: ^4.9.0
|
||||||
|
|||||||
Reference in New Issue
Block a user