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

@@ -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"`
} }

View File

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

View File

@@ -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
@@ -191,6 +199,8 @@ func (handler *Handler) RecognizeDish(responseWriter http.ResponseWriter, reques
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)

View File

@@ -20,7 +20,7 @@ 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
@@ -28,6 +28,8 @@ type Job struct {
ImageBase64 string ImageBase64 string
MimeType string MimeType string
Lang string Lang string
TargetDate *string // nullable YYYY-MM-DD
TargetMealType *string // nullable e.g. "lunch"
Status string Status string
Result *ai.DishResult Result *ai.DishResult
Error *string Error *string
@@ -35,3 +37,14 @@ type Job struct {
StartedAt *time.Time StartedAt *time.Time
CompletedAt *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"`
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
@@ -403,30 +428,11 @@ CREATE TABLE meal_diary (
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;

View File

@@ -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: [

View 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);
}

View 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'),
);

View File

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

View File

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

View File

@@ -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'),
),
),
], ],
); );
} }

View File

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

View 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,
),
);
}
}

View File

@@ -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,42 +267,56 @@ 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();
@@ -285,9 +335,7 @@ class RecognitionService {
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;
} }
@@ -298,6 +346,9 @@ class RecognitionService {
..clear() ..clear()
..write(remaining); ..write(remaining);
} }
} finally {
httpClient.close();
}
} }
DishJobEvent? _parseSseEvent(String? eventName, String dataPayload) { DishJobEvent? _parseSseEvent(String? eventName, String dataPayload) {

View File

@@ -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(),
), ),
); );
} }

View File

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

View File

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