From bf8dce36c529fac905c5d05a72d679482c082739 Mon Sep 17 00:00:00 2001 From: dbastrikin Date: Sat, 21 Mar 2026 16:08:20 +0200 Subject: [PATCH] fix: show dish calories in search and fix portion sheet layout crash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DishSearchResult now carries calories_per_serving (backend entity + repo LEFT JOIN recipes / MIN / GROUP BY; Flutter model + fromJson) - _FoodTile.fromDish shows kcal/serving subtitle when available - _DishPortionSheet quick-select buttons: Row → Wrap to avoid BoxConstraints infinite-width crash inside DraggableScrollableSheet Co-Authored-By: Claude Sonnet 4.6 --- backend/internal/domain/dish/entity.go | 9 ++--- backend/internal/domain/dish/repository.go | 5 ++- .../features/diary/food_search_service.dart | 4 +++ .../lib/features/diary/food_search_sheet.dart | 36 +++++++++---------- 4 files changed, 31 insertions(+), 23 deletions(-) diff --git a/backend/internal/domain/dish/entity.go b/backend/internal/domain/dish/entity.go index b7380ee..5a55a3a 100644 --- a/backend/internal/domain/dish/entity.go +++ b/backend/internal/domain/dish/entity.go @@ -61,10 +61,11 @@ type RecipeStep struct { // DishSearchResult is a lightweight dish returned by the search endpoint. type DishSearchResult struct { - ID string `json:"id"` - Name string `json:"name"` - ImageURL *string `json:"image_url,omitempty"` - AvgRating float64 `json:"avg_rating"` + ID string `json:"id"` + Name string `json:"name"` + ImageURL *string `json:"image_url,omitempty"` + AvgRating float64 `json:"avg_rating"` + CaloriesPerServing *float64 `json:"calories_per_serving,omitempty"` } // CreateRequest is the body used to create a new dish + recipe at once. diff --git a/backend/internal/domain/dish/repository.go b/backend/internal/domain/dish/repository.go index a796d9e..9a4639d 100644 --- a/backend/internal/domain/dish/repository.go +++ b/backend/internal/domain/dish/repository.go @@ -111,6 +111,7 @@ func (r *Repository) Search(ctx context.Context, query string, limit int) ([]*Di COALESCE(dt.name, d.name) AS name, d.image_url, d.avg_rating, + MIN(r.calories_per_serving) AS calories_per_serving, GREATEST( ts_rank(to_tsvector('english', d.name), plainto_tsquery('english', $1)), ts_rank(to_tsvector('simple', COALESCE(dt.name, '')), plainto_tsquery('simple', $1)), @@ -118,6 +119,7 @@ func (r *Repository) Search(ctx context.Context, query string, limit int) ([]*Di ) AS rank FROM dishes d LEFT JOIN dish_translations dt ON dt.dish_id = d.id AND dt.lang = $3 + LEFT JOIN recipes r ON r.dish_id = d.id WHERE ( to_tsvector('english', d.name) @@ plainto_tsquery('english', $1) OR to_tsvector('simple', COALESCE(dt.name, '')) @@ plainto_tsquery('simple', $1) @@ -125,6 +127,7 @@ func (r *Repository) Search(ctx context.Context, query string, limit int) ([]*Di OR dt.name ILIKE '%' || $1 || '%' OR similarity(COALESCE(dt.name, d.name), $1) > 0.3 ) + GROUP BY d.id, dt.name, d.name, d.image_url, d.avg_rating ORDER BY rank DESC LIMIT $2` @@ -138,7 +141,7 @@ func (r *Repository) Search(ctx context.Context, query string, limit int) ([]*Di for rows.Next() { var result DishSearchResult var rank float64 - if scanError := rows.Scan(&result.ID, &result.Name, &result.ImageURL, &result.AvgRating, &rank); scanError != nil { + if scanError := rows.Scan(&result.ID, &result.Name, &result.ImageURL, &result.AvgRating, &result.CaloriesPerServing, &rank); scanError != nil { return nil, fmt.Errorf("scan dish search result: %w", scanError) } results = append(results, &result) diff --git a/client/lib/features/diary/food_search_service.dart b/client/lib/features/diary/food_search_service.dart index 76532f8..46f5cb9 100644 --- a/client/lib/features/diary/food_search_service.dart +++ b/client/lib/features/diary/food_search_service.dart @@ -7,12 +7,14 @@ class DishSearchResult { final String name; final String? imageUrl; final double avgRating; + final double? caloriesPerServing; const DishSearchResult({ required this.id, required this.name, this.imageUrl, required this.avgRating, + this.caloriesPerServing, }); factory DishSearchResult.fromJson(Map json) { @@ -21,6 +23,8 @@ class DishSearchResult { name: json['name'] as String, imageUrl: json['image_url'] as String?, avgRating: (json['avg_rating'] as num?)?.toDouble() ?? 0, + caloriesPerServing: + (json['calories_per_serving'] as num?)?.toDouble(), ); } } diff --git a/client/lib/features/diary/food_search_sheet.dart b/client/lib/features/diary/food_search_sheet.dart index 7970542..7519100 100644 --- a/client/lib/features/diary/food_search_sheet.dart +++ b/client/lib/features/diary/food_search_sheet.dart @@ -484,7 +484,9 @@ class _FoodTile extends StatelessWidget { size: 20, color: Colors.green), ), title: dish.name, - subtitle: null, + subtitle: dish.caloriesPerServing != null + ? '${dish.caloriesPerServing!.toInt()} kcal / serving' + : null, onTap: onTap, ); } @@ -635,27 +637,25 @@ class _DishPortionSheetState extends ConsumerState<_DishPortionSheet> { const SizedBox(height: 16), // Quick-select portion buttons - Row( + Wrap( + spacing: 8, children: [ for (final quickValue in [0.5, 1.0, 1.5, 2.0]) - Padding( - padding: const EdgeInsets.only(right: 8), - child: OutlinedButton( - onPressed: () => _setPortions(quickValue), - style: OutlinedButton.styleFrom( - padding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 8), - side: BorderSide( - color: _selectedPortions == quickValue - ? theme.colorScheme.primary - : theme.colorScheme.outline, - width: _selectedPortions == quickValue ? 2 : 1, - ), + OutlinedButton( + onPressed: () => _setPortions(quickValue), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 8), + side: BorderSide( + color: _selectedPortions == quickValue + ? theme.colorScheme.primary + : theme.colorScheme.outline, + width: _selectedPortions == quickValue ? 2 : 1, ), - child: Text(quickValue % 1 == 0 - ? quickValue.toInt().toString() - : quickValue.toStringAsFixed(1)), ), + child: Text(quickValue % 1 == 0 + ? quickValue.toInt().toString() + : quickValue.toStringAsFixed(1)), ), ], ),