Commit Graph

23 Commits

Author SHA1 Message Date
dbastrikin
c7317c4335 feat: async product/receipt recognition via Kafka
Backend:
- Migration 002: product_recognition_jobs table with JSONB images column
  and job_type CHECK ('receipt' | 'products')
- New Kafka topics: ai.products.paid / ai.products.free
- ProductJob model, ProductJobRepository (mirrors dish job pattern)
- itemEnricher extracted from Handler — shared by HTTP handler and worker
- ProductSSEBroker: PG LISTEN on product_job_update channel
- ProductWorkerPool: 5 workers, branches on job_type to call
  RecognizeReceipt or RecognizeProducts per image in parallel
- Handler: RecognizeReceipt and RecognizeProducts now return 202 Accepted
  instead of blocking; 4 new endpoints: GET /ai/product-jobs,
  /product-jobs/history, /product-jobs/{id}, /product-jobs/{id}/stream
- cmd/worker: extended to run ProductWorkerPool alongside dish WorkerPool
- cmd/server: wires productJobRepository + productSSEBroker; both SSE
  brokers started in App.Start()

Flutter client:
- ProductJobCreated, ProductJobResult, ProductJobSummary, ProductJobEvent
  models + submitReceiptRecognition/submitProductsRecognition/stream methods
- Shared _openSseStream helper eliminates duplicate SSE parsing loop
- ScanScreen: replace blocking AI calls with async submit + navigate to
  ProductJobWatchScreen
- ProductJobWatchScreen: watches SSE stream, navigates to /scan/confirm
  when done, shows error on failure
- ProductsScreen: prepends _RecentScansSection (hidden when empty); compact
  horizontal list of recent scans with "See all" → history
- ProductJobHistoryScreen: full list of all product recognition jobs
- New routes: /scan/product-job-watch, /products/job-history
- L10n: 7 new keys in all 12 ARB files

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 23:01:30 +02:00
dbastrikin
edf587e798 chore: squash migrations 003–005 into 001_initial_schema
Fold all incremental schema changes into the baseline migration so that
a fresh database only needs to run one file:
- recipe_source enum now includes 'menu'
- recipe_products/recipe_product_translations renamed to
  recipe_ingredients/recipe_ingredient_translations; product_id → ingredient_id
- menu_items.meal_type CHECK expanded to all 6 types
  (breakfast, second_breakfast, lunch, afternoon_snack, dinner, snack)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 21:52:26 +02:00
dbastrikin
9580bff54e feat: flexible meal planning wizard — plan 1 meal, 1 day, several days, or a week
Backend:
- migration 005: expand menu_items.meal_type CHECK to all 6 types (second_breakfast, afternoon_snack, snack)
- ai/types.go: add Days and MealTypes to MenuRequest for partial generation
- openai/menu.go: parametrize GenerateMenu — use requested meal types and day count; add caloric fractions for all 6 meal types
- menu/repository.go: add UpsertItemsInTx for partial upsert (preserves existing slots); fix meal_type sort order in GetByWeek
- menu/handler.go: add dates+meal_types path to POST /ai/generate-menu; extract fetchImages/saveRecipes helpers; returns {"plans":[...]} for dates mode; backward-compatible with week mode

Client:
- PlanMenuSheet: bottom sheet with 4 planning horizon options
- PlanDatePickerSheet: adaptive sheet with date strip (single day/meal) or custom CalendarRangePicker (multi-day/week); sliding 7-day window for week mode
- menu_service.dart: add generateForDates
- menu_provider.dart: add PlanMenuService (generates + invalidates week providers), lastPlannedDateProvider
- home_screen.dart: add _PlanMenuButton card below quick actions; opens planning wizard
- l10n: 16 new keys for planning UI across all 12 languages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 12:10:52 +02:00
dbastrikin
5096df2102 fix: fix menu generation errors and show planned meals on home screen
Backend fixes:
- migration 003: add 'menu' value to recipe_source enum (was causing SQLSTATE 22P02)
- migration 004: rename recipe_products→recipe_ingredients, product_id→ingredient_id (was causing SQLSTATE 42P01)
- dish/repository.go: fix INSERT INTO tags using $1/$1 for two columns → $1/$2 (was causing SQLSTATE 42P08)
- home/handler.go: replace non-existent saved_recipes table with correct joins (recipes→dishes→dish_translations, user_saved_recipes) so today's plan and recommendations load correctly
- reqlog: new slog.Handler wrapper that adds request_id and stack trace to ERROR-level logs
- all handlers: slog.Error→slog.ErrorContext so error logs include request context; writeError includes request_id in response body

Client:
- home_screen.dart: extend home screen to future dates, show planned meals as ghost entries
- l10n: add new localisation keys for home screen date navigation and planned meal UI

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 00:35:11 +02:00
dbastrikin
6af7d2fade fix: change products.canonical_name to TEXT, trim whitespace on import
VARCHAR(255) was too narrow for some OpenFoodFacts product names.
Switching to TEXT removes the length constraint entirely.
Also trim whitespace from resolved canonical names during import.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 15:33:49 +02:00
dbastrikin
78f1c8bf76 feat: food search sheet with FTS+trgm, dish/recent endpoints, multilingual aliases
Backend:
- GET /dishes/search — hybrid FTS (english + simple) + trgm + ILIKE search
- GET /diary/recent — recently used dishes and products for the current user
- product search upgraded: FTS on canonical_name and product_aliases, ranked by GREATEST(ts_rank, similarity)
- importoff: collect product_name_ru/de/fr/... as product_aliases for multilingual search (e.g. "сникерс" → "Snickers")
- migrations: FTS + trgm indexes merged into 001_initial_schema.sql (002 removed)

Flutter:
- FoodSearchSheet: debounced search field, recently-used section, product/dish results, scan-photo and barcode chips
- DishPortionSheet: quick ½/1/1½/2 buttons + custom input
- + button in meal card now opens FoodSearchSheet instead of going directly to AI scan
- 7 new l10n keys across all 12 languages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 15:28:29 +02:00
dbastrikin
205edbdade feat: rename ingredients→products, products→user_products; add barcode/OFF import
- Rename catalog: ingredient/* → product/* (canonical_name, barcode, nutrition per 100g)
- Rename pantry: product/* → userproduct/* (user-owned items with expiry)
- Squash migrations into single 001_initial_schema.sql (clean-db baseline)
- product_categories: add English canonical name column; fix COALESCE in queries
- Remove product_translations: product names are stored in their original language
- Add default_unit_name to product API responses via unit_translations JOIN
- Add cmd/importoff: bulk import from OpenFoodFacts JSONL dump (COPY + ON CONFLICT)
- Diary: support product_id entries alongside dish_id (CHECK num_nonnulls = 1)
- Home: getLoggedCalories joins both recipes and catalog products
- Flutter: rename models/providers/services to match backend rename
- Flutter: add barcode scan flow for diary (mobile_scanner, product_portion_sheet)
- Flutter: localise 6 new keys across 12 languages (barcode scan, portion weight)
- Routes: GET /products/search, GET /products/barcode/{barcode}, /user-products

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 12:45:48 +02:00
dbastrikin
cf69a4a3d9 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>
2026-03-19 16:11:21 +02:00
dbastrikin
39193ec13c feat: async dish recognition (Kafka/Watermill/SSE) + remove Wire + consolidate migrations
Async recognition pipeline:
- POST /ai/recognize-dish → 202 {job_id, queue_position, estimated_seconds}
- GET /ai/jobs/{id}/stream — SSE stream: queued → processing → done/failed
- Kafka topics: ai.recognize.paid (3 partitions) + ai.recognize.free (1 partition)
- 5-worker WorkerPool with priority loop (paid consumers first)
- SSEBroker via PostgreSQL LISTEN/NOTIFY
- Kafka adapter migrated from franz-go to Watermill (watermill-kafka/v2)
- Docker Compose: added Kafka + Zookeeper + kafka-init service
- Flutter: recognition_service.dart uses SSE; home_screen shows live job status

Remove google/wire (archived):
- Deleted wire.go (wireinject spec) and wire_gen.go
- Added cmd/server/init.go — plain Go manual DI, same initApp() logic
- Removed github.com/google/wire from go.mod

Consolidate migrations:
- Merged 001_initial_schema + 002_seed_data + 003_recognition_jobs into single 001_initial_schema.sql
- Deleted 002_seed_data.sql and 003_recognition_jobs.sql

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 16:32:06 +02:00
dbastrikin
ad00998344 feat: slim meal_diary — derive name and nutrition from dish/recipe
Remove denormalized columns (name, calories, protein_g, fat_g, carbs_g)
from meal_diary. Name is now resolved via JOIN with dishes/dish_translations;
macros are computed as recipe.*_per_serving * portions at query time.

- Add dish.Repository.FindOrCreateRecipe: finds or creates a minimal recipe
  stub seeded with AI-estimated macros
- recognition/handler: resolve recipe_id synchronously per candidate;
  simplify enrichDishInBackground to translations-only
- diary/handler: accept dish_id OR name; always resolve recipe_id via
  FindOrCreateRecipe before INSERT
- diary/entity: DishID is now non-nullable string; CreateRequest drops macros
- diary/repository: ListByDate and Create use JOIN to return computed macros
- ai/types: add RecipeID field to DishCandidate
- Update tests and wire_gen accordingly

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 13:28:37 +02:00
dbastrikin
67d8897c95 fix: wrap uuid_generate_v7 function in goose StatementBegin/End
goose splits SQL on semicolons and misinterprets dollar-quoted strings
($$...$$) as statement boundaries, causing a parse error on the PL/pgSQL
function body. StatementBegin/End annotations tell goose to treat the
block as a single statement.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 11:42:22 +02:00
dbastrikin
61feb91bba feat: core schema redesign — dishes, structured recipes, cuisines, tags (iteration 7)
Replaces the flat JSONB-based recipe schema with a normalized relational model:

Schema (migrations consolidated to 001_initial_schema + 002_seed_data):
- New: dishes, dish_translations, dish_tags — canonical dish catalog
- New: cuisines, tags, dish_categories with _translations tables + full seed data
- New: recipe_ingredients, recipe_steps with _translations (replaces JSONB blobs)
- New: user_saved_recipes thin bookmark (drops saved_recipes + saved_recipe_translations)
- New: product_ingredients M2M table
- recipes: now a cooking variant of a dish (dish_id FK, no title/JSONB columns)
- recipe_translations: repurposed to per-language notes only
- products: mapping_id → primary_ingredient_id
- menu_items: recipe_id FK → recipes; adds dish_id
- meal_diary: adds dish_id, recipe_id → recipes, portion_g

Backend (Go):
- New packages: internal/cuisine, internal/tag, internal/dish (registry + handler + repo)
- New GET /cuisines, GET /tags (public), GET /dishes, GET /dishes/{id}, GET /recipes/{id}
- recipe, savedrecipe, menu, diary, product, ingredient packages updated for new schema

Flutter:
- New models: Cuisine, Tag; new providers: cuisineNamesProvider, tagNamesProvider
- recipe.dart: RecipeIngredient gains unit_code + effectiveUnit getter
- saved_recipe.dart: thin model, manual fromJson, computed nutrition getter
- diary_entry.dart: adds dishId, recipeId, portionG
- recipe_detail_screen.dart: localized cuisine/tag names via providers

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 18:01:24 +02:00
dbastrikin
55d01400b0 feat: dynamic units table with localized names via GET /units
- Add units + unit_translations tables with FK constraints on products and ingredient_mappings
- Normalize products.unit from Russian strings (г, кг) to English codes (g, kg)
- Load units at startup (in-memory registry) and serve via GET /units (language-aware)
- Replace hardcoded _units lists and _mapUnit() functions in Flutter with unitsProvider FutureProvider
- Re-fetches automatically when language changes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 16:15:33 +02:00
dbastrikin
e1fbe7b1a2 feat: move supported languages to DB table, expose via GET /languages
- migration 013: create languages table (code PK, native_name, english_name,
  is_active, sort_order) with all 12 existing languages seeded
- locale: add Language struct, Languages []Language, LoadFromDB() — queries
  languages table at startup and populates both Supported map and Languages
  slice; existing Parse/FromContext/FromRequest unchanged
- main.go: call locale.LoadFromDB after pool is ready
- gemini/recipe.go: remove hardcoded langNames map, use locale.Languages
  linear lookup for English name in prompt
- language/handler.go: new package with GET /languages handler returning
  active languages list (no auth required)
- server.go: register GET /languages as public route
- Flutter: add LanguageRepository + languageRepositoryProvider that fetches
  /languages from backend
- language_provider.dart: replace const supportedLanguages map with
  supportedLanguagesProvider (FutureProvider) backed by LanguageRepository
- profile_provider.dart: remove supportedLanguages.containsKey validation —
  backend is source of truth; sync any non-empty language from preferences
- profile_screen.dart: use supportedLanguagesProvider for display name and
  dropdown (async with loading/error states)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 23:08:10 +02:00
dbastrikin
a225f6c47a refactor: migrate ingredient aliases/categories to dedicated tables, drop spoonacular_id
- migration 012: create ingredient_categories + ingredient_category_translations
  tables (7 slugs, Russian names); add ingredient_aliases (ingredient_id, lang,
  alias) with GIN trigram index; migrate aliases JSONB from ingredient_mappings
  and ingredient_translations; drop aliases columns and spoonacular_id; add
  UNIQUE (canonical_name) as conflict key
- ingredient/model: remove SpoonacularID, add CategoryName for localized display
- ingredient/repository: conflict on canonical_name; GetByID/Search join category
  translations and aliases lateral; new UpsertAliases (pgx batch),
  UpsertCategoryTranslation; remove GetBySpoonacularID; split scan helpers into
  scanMappingWrite / scanMappingRead
- gemini/recognition: add IngredientTranslation type; IngredientClassification
  now carries Translations []IngredientTranslation instead of CanonicalNameRu;
  update ClassifyIngredient prompt to English with structured translations array
- recognition/handler: update ingredientRepo interface; saveClassification uses
  UpsertAliases and iterates Translations
- recipe/model: remove SpoonacularID from RecipeIngredient
- integration tests: remove SpoonacularID fixtures, replace GetBySpoonacularID
  tests with GetByID, add UpsertAliases and UpsertCategoryTranslation tests
- Flutter: remove canonicalNameRu from IngredientMapping, add categoryName;
  displayName returns server-resolved canonicalName; regenerate .g.dart

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 14:40:07 +02:00
dbastrikin
c9ddb708b1 feat: replace age integer with date_of_birth across backend and client
Store date_of_birth (DATE) instead of a static age integer so that age
is always computed dynamically from the stored date of birth.

- Migration 011: adds date_of_birth, backfills from age, drops age
- AgeFromDOB helper computes current age from YYYY-MM-DD string
- User model, repository SQL, and service validation updated
- Flutter: User.age becomes a computed getter; profile edit screen
  uses a date picker bounded to [today-120y, today-10y]

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 23:37:58 +02:00
dbastrikin
c0cf1b38ea feat: implement backend localization infrastructure
- Add internal/locale package: Parse(Accept-Language), FromContext/WithLang helpers, 12 supported languages
- Add Language middleware that reads Accept-Language header and stores lang in context
- Register Language middleware globally in server router (after CORS)

Database migrations:
- 009: create recipe_translations, saved_recipe_translations, ingredient_translations tables; migrate existing _ru data
- 010: drop legacy _ru columns (title_ru, description_ru, canonical_name_ru); update FTS index

Models: remove all _ru fields (TitleRu, DescriptionRu, NameRu, UnitRu, CanonicalNameRu)

Repositories:
- recipe: Upsert drops _ru params; GetByID does LEFT JOIN COALESCE on recipe_translations; ListMissingTranslation(lang); UpsertTranslation
- ingredient: same pattern with ingredient_translations; Search now queries translated names/aliases
- savedrecipe: List/GetByID LEFT JOIN COALESCE on saved_recipe_translations; UpsertTranslation

Gemini:
- RecipeRequest/MenuRequest gain Lang field
- buildRecipePrompt rewritten in English with target-language content instruction; image_query always in English
- GenerateMenu propagates Lang to GenerateRecipes

Handlers:
- recommendation/menu: pass locale.FromContext(ctx) as Lang
- recognition: saveClassification stores Russian translation via UpsertTranslation instead of _ru column

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 23:17:34 +02:00
dbastrikin
ea4a6301ea feat: replace UUID v4 with UUID v7 across backend
Define uuid_generate_v7() in the first migration and switch all table
primary key defaults to it. Remove the uuid-ossp extension dependency.
Update refresh token and request ID generation in Go code to use
uuid.NewV7() from the existing google/uuid v1.6.0 library.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 13:19:55 +02:00
dbastrikin
ea8e207a45 feat: implement Iteration 4 — menu planning, shopping list, diary
Backend:
- Migrations 007 (menu_plans, menu_items, shopping_lists) and
  008 (meal_diary)
- gemini/menu.go: GenerateMenu — 7-day × 3-meal plan via one Groq call
- internal/menu: model, repository (GetByWeek, SaveMenuInTx,
  shopping list CRUD), handler (GET/PUT/DELETE /menu,
  POST /ai/generate-menu, shopping list endpoints)
- internal/diary: model, repository, handler (GET/POST/DELETE /diary)
- Increase server WriteTimeout to 120s for long AI calls
- api_client.go: add patch() and postList() helpers

Flutter:
- shared/models: menu.dart, shopping_item.dart, diary_entry.dart
- features/menu: menu_service.dart, menu_provider.dart
  (MenuNotifier, ShoppingListNotifier, DiaryNotifier with family)
- MenuScreen: 7-day view, week nav, skeleton on generation,
  generate FAB with confirmation dialog
- ShoppingListScreen: items by category, optimistic checkbox toggle
- DiaryScreen: daily entries with swipe-to-delete, add-entry sheet
- Router: /menu/shopping-list and /menu/diary routes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-22 12:00:25 +02:00
dbastrikin
288bb1c375 fix: compute expires_at in SQL instead of generated column
TIMESTAMPTZ + INTERVAL is STABLE (depends on timezone), not IMMUTABLE,
so PostgreSQL rejects it in GENERATED ALWAYS AS STORED columns.
Fix: remove generated column and compute expires_at inline in each query
as (added_at + storage_days * INTERVAL '1 day').

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-21 23:24:42 +02:00
dbastrikin
b9b9e9fe11 feat: implement Iteration 2 — product management
Backend:
- migrations/005: add pg_trgm extension + search indexes on ingredient_mappings
- migrations/006: products table with computed expires_at column
- ingredient: add Search method (aliases + ILIKE + trgm) + HTTP handler
- product: full package — model, repository (CRUD + BatchCreate + ListForPrompt), handler
- gemini: add AvailableProducts field to RecipeRequest, include in prompt
- recommendation: add ProductLister interface, load user products for personalised prompts
- server/main: wire ingredient and product handlers with new routes

Flutter:
- models: Product, IngredientMapping with json_serializable
- ProductService: getProducts, createProduct, updateProduct, deleteProduct, searchIngredients
- ProductsNotifier: create/update/delete with optimistic delete
- ProductsScreen: expiring-soon section, normal section, swipe-to-delete, edit bottom sheet
- AddProductScreen: name field with 300ms debounce autocomplete, qty/unit/days fields
- app_router: /products/add route + Badge on Products nav tab showing expiring count
- MainShell converted to ConsumerWidget for badge reactivity

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-21 23:22:30 +02:00
dbastrikin
e57ff8e06c feat: implement Iteration 1 — AI recipe recommendations
Backend:
- Add Groq LLM client (llama-3.3-70b) for recipe generation with JSON
  retry strategy (retries only on parse errors, not API errors)
- Add Pexels client for parallel photo search per recipe
- Add saved_recipes table (migration 004) with JSONB fields
- Add GET /recommendations endpoint (profile-aware prompt building)
- Add POST/GET/GET{id}/DELETE /saved-recipes CRUD endpoints
- Wire gemini, pexels, recommendation, savedrecipe packages in main.go

Flutter:
- Add Recipe, SavedRecipe models with json_serializable
- Add RecipeService (getRecommendations, getSavedRecipes, save, delete)
- Add RecommendationsNotifier and SavedRecipesNotifier (Riverpod)
- Add RecommendationsScreen with skeleton loading and refresh FAB
- Add RecipeDetailScreen (SliverAppBar, nutrition tooltip, steps with timer)
- Add SavedRecipesScreen with Dismissible swipe-to-delete and empty state
- Update RecipesScreen to TabBar (Recommendations / Saved)
- Add /recipe-detail route outside ShellRoute (no bottom nav)
- Extend ApiClient with getList() and deleteVoid()

Project:
- Add CLAUDE.md with English-only rule for comments and commit messages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-21 22:43:29 +02:00
dbastrikin
24219b611e feat: implement Iteration 0 foundation (backend + Flutter client)
Backend (Go):
- Project structure with chi router, pgxpool, goose migrations
- JWT auth (access/refresh tokens) with Firebase token verification
- NoopTokenVerifier for local dev without Firebase credentials
- PostgreSQL user repository with atomic profile updates (transactions)
- Mifflin-St Jeor calorie calculation based on profile data
- REST API: POST /auth/login, /auth/refresh, /auth/logout, GET/PUT /profile, GET /health
- Middleware: auth, CORS (localhost wildcard), logging, recovery, request_id
- Unit tests (51 passing) and integration tests (testcontainers)
- Docker Compose setup with postgres healthcheck and graceful shutdown

Flutter client:
- Riverpod state management with GoRouter navigation
- Firebase Auth (email/password + Google sign-in with web popup support)
- Platform-aware API URLs (web/Android/iOS)
- Dio HTTP client with JWT auth interceptor and concurrent refresh handling
- Secure token storage
- Screens: Login, Register, Home (tabs: Menu, Recipes, Products, Profile)
- Unit tests (17 passing)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 13:14:58 +02:00