fix: show dish calories in search and fix portion sheet layout crash

- 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 <noreply@anthropic.com>
This commit is contained in:
dbastrikin
2026-03-21 16:08:20 +02:00
parent 6af7d2fade
commit bf8dce36c5
4 changed files with 31 additions and 23 deletions

View File

@@ -61,10 +61,11 @@ type RecipeStep struct {
// DishSearchResult is a lightweight dish returned by the search endpoint. // DishSearchResult is a lightweight dish returned by the search endpoint.
type DishSearchResult struct { type DishSearchResult struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
ImageURL *string `json:"image_url,omitempty"` ImageURL *string `json:"image_url,omitempty"`
AvgRating float64 `json:"avg_rating"` 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. // CreateRequest is the body used to create a new dish + recipe at once.

View File

@@ -111,6 +111,7 @@ func (r *Repository) Search(ctx context.Context, query string, limit int) ([]*Di
COALESCE(dt.name, d.name) AS name, COALESCE(dt.name, d.name) AS name,
d.image_url, d.image_url,
d.avg_rating, d.avg_rating,
MIN(r.calories_per_serving) AS calories_per_serving,
GREATEST( GREATEST(
ts_rank(to_tsvector('english', d.name), plainto_tsquery('english', $1)), ts_rank(to_tsvector('english', d.name), plainto_tsquery('english', $1)),
ts_rank(to_tsvector('simple', COALESCE(dt.name, '')), plainto_tsquery('simple', $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 ) AS rank
FROM dishes d FROM dishes d
LEFT JOIN dish_translations dt ON dt.dish_id = d.id AND dt.lang = $3 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 ( WHERE (
to_tsvector('english', d.name) @@ plainto_tsquery('english', $1) to_tsvector('english', d.name) @@ plainto_tsquery('english', $1)
OR to_tsvector('simple', COALESCE(dt.name, '')) @@ plainto_tsquery('simple', $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 dt.name ILIKE '%' || $1 || '%'
OR similarity(COALESCE(dt.name, d.name), $1) > 0.3 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 ORDER BY rank DESC
LIMIT $2` LIMIT $2`
@@ -138,7 +141,7 @@ func (r *Repository) Search(ctx context.Context, query string, limit int) ([]*Di
for rows.Next() { for rows.Next() {
var result DishSearchResult var result DishSearchResult
var rank float64 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) return nil, fmt.Errorf("scan dish search result: %w", scanError)
} }
results = append(results, &result) results = append(results, &result)

View File

@@ -7,12 +7,14 @@ class DishSearchResult {
final String name; final String name;
final String? imageUrl; final String? imageUrl;
final double avgRating; final double avgRating;
final double? caloriesPerServing;
const DishSearchResult({ const DishSearchResult({
required this.id, required this.id,
required this.name, required this.name,
this.imageUrl, this.imageUrl,
required this.avgRating, required this.avgRating,
this.caloriesPerServing,
}); });
factory DishSearchResult.fromJson(Map<String, dynamic> json) { factory DishSearchResult.fromJson(Map<String, dynamic> json) {
@@ -21,6 +23,8 @@ class DishSearchResult {
name: json['name'] as String, name: json['name'] as String,
imageUrl: json['image_url'] as String?, imageUrl: json['image_url'] as String?,
avgRating: (json['avg_rating'] as num?)?.toDouble() ?? 0, avgRating: (json['avg_rating'] as num?)?.toDouble() ?? 0,
caloriesPerServing:
(json['calories_per_serving'] as num?)?.toDouble(),
); );
} }
} }

View File

@@ -484,7 +484,9 @@ class _FoodTile extends StatelessWidget {
size: 20, color: Colors.green), size: 20, color: Colors.green),
), ),
title: dish.name, title: dish.name,
subtitle: null, subtitle: dish.caloriesPerServing != null
? '${dish.caloriesPerServing!.toInt()} kcal / serving'
: null,
onTap: onTap, onTap: onTap,
); );
} }
@@ -635,27 +637,25 @@ class _DishPortionSheetState extends ConsumerState<_DishPortionSheet> {
const SizedBox(height: 16), const SizedBox(height: 16),
// Quick-select portion buttons // Quick-select portion buttons
Row( Wrap(
spacing: 8,
children: [ children: [
for (final quickValue in [0.5, 1.0, 1.5, 2.0]) for (final quickValue in [0.5, 1.0, 1.5, 2.0])
Padding( OutlinedButton(
padding: const EdgeInsets.only(right: 8), onPressed: () => _setPortions(quickValue),
child: OutlinedButton( style: OutlinedButton.styleFrom(
onPressed: () => _setPortions(quickValue), padding: const EdgeInsets.symmetric(
style: OutlinedButton.styleFrom( horizontal: 12, vertical: 8),
padding: const EdgeInsets.symmetric( side: BorderSide(
horizontal: 12, vertical: 8), color: _selectedPortions == quickValue
side: BorderSide( ? theme.colorScheme.primary
color: _selectedPortions == quickValue : theme.colorScheme.outline,
? theme.colorScheme.primary width: _selectedPortions == quickValue ? 2 : 1,
: 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)),
), ),
], ],
), ),