597 lines
19 KiB
Dart
597 lines
19 KiB
Dart
import 'dart:math' as math;
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
import 'package:intl/intl.dart';
|
|
|
|
import '../../../core/api/api_error_mapper.dart';
|
|
import '../../../core/utils/formatters.dart';
|
|
import '../../../core/l10n/l10n.dart';
|
|
import '../../../core/ui/async_state_views.dart';
|
|
import '../../auth/data/auth_providers.dart';
|
|
import '../../recipes/data/recipe_providers.dart';
|
|
import '../../recipes/domain/recipe.dart';
|
|
import '../data/meal_plan_providers.dart';
|
|
import '../domain/inventory_compare_item.dart';
|
|
import '../domain/meal_plan_dashboard.dart';
|
|
import '../domain/meal_plan_entry.dart';
|
|
import '../domain/meal_plan_week.dart';
|
|
import '../domain/shopping_item.dart';
|
|
|
|
class MealPlanScreen extends ConsumerStatefulWidget {
|
|
const MealPlanScreen({super.key});
|
|
|
|
@override
|
|
ConsumerState<MealPlanScreen> createState() => _MealPlanScreenState();
|
|
}
|
|
|
|
class _MealPlanScreenState extends ConsumerState<MealPlanScreen> {
|
|
String? _savingDate;
|
|
|
|
Future<void> _saveSelection({
|
|
required String date,
|
|
required int? recipeId,
|
|
int? servings,
|
|
}) async {
|
|
if (_savingDate != null) return;
|
|
|
|
setState(() => _savingDate = date);
|
|
try {
|
|
final token = await ref.read(authStateProvider.future);
|
|
final repository = ref.read(mealPlanRepositoryProvider);
|
|
|
|
if (recipeId == null) {
|
|
await repository.deleteByDate(date, token: token);
|
|
} else {
|
|
await repository.upsert(
|
|
date: date,
|
|
recipeId: recipeId,
|
|
servings: servings,
|
|
token: token,
|
|
);
|
|
}
|
|
|
|
ref.invalidate(mealPlanDashboardProvider);
|
|
} catch (error) {
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
buildCopyableErrorSnackBar(context, mapErrorToUserMessage(error, context))),
|
|
);
|
|
} finally {
|
|
if (mounted) {
|
|
setState(() => _savingDate = null);
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = context.l10n;
|
|
final locale = Localizations.localeOf(context).toLanguageTag();
|
|
final recipesAsync = ref.watch(recipesProvider);
|
|
final dashboardAsync = ref.watch(mealPlanDashboardProvider);
|
|
final week = ref.watch(mealPlanWeekProvider);
|
|
|
|
if (recipesAsync.isLoading || dashboardAsync.isLoading) {
|
|
return LoadingStateView(label: l10n.mealPlanLoading);
|
|
}
|
|
|
|
if (recipesAsync.hasError || dashboardAsync.hasError) {
|
|
final error = recipesAsync.error ?? dashboardAsync.error;
|
|
return ErrorStateView(
|
|
message: mapErrorToUserMessage(error ?? l10n.unexpectedError, context),
|
|
onRetry: () {
|
|
ref.invalidate(recipesProvider);
|
|
ref.invalidate(mealPlanDashboardProvider);
|
|
},
|
|
);
|
|
}
|
|
|
|
final recipes = recipesAsync.maybeWhen(data: (d) => d, orElse: () => null) ?? const <Recipe>[];
|
|
final dashboard = dashboardAsync.maybeWhen(data: (d) => d, orElse: () => null) ??
|
|
const MealPlanDashboard(
|
|
entries: [],
|
|
shoppingItems: [],
|
|
inventoryCompareItems: [],
|
|
);
|
|
|
|
if (recipes.isEmpty) {
|
|
return EmptyStateView(
|
|
title: l10n.mealPlanNoRecipesTitle,
|
|
description: l10n.mealPlanNoRecipesDescription,
|
|
);
|
|
}
|
|
|
|
final plannedCount = week.days.where((day) => dashboard.entryForDate(day) != null).length;
|
|
final weekLabel = _formatWeekLabel(week, locale);
|
|
|
|
return ListView(
|
|
padding: const EdgeInsets.all(12),
|
|
children: [
|
|
Wrap(
|
|
spacing: 8,
|
|
runSpacing: 8,
|
|
crossAxisAlignment: WrapCrossAlignment.center,
|
|
children: [
|
|
OutlinedButton.icon(
|
|
onPressed: () => ref.read(mealPlanWeekOffsetProvider.notifier).decrement(),
|
|
icon: const Icon(Icons.chevron_left),
|
|
label: Text(l10n.mealPlanWeekPrevious),
|
|
),
|
|
Chip(label: Text(weekLabel)),
|
|
OutlinedButton.icon(
|
|
onPressed: () => ref.read(mealPlanWeekOffsetProvider.notifier).increment(),
|
|
icon: const Icon(Icons.chevron_right),
|
|
label: Text(l10n.mealPlanWeekNext),
|
|
),
|
|
if (ref.watch(mealPlanWeekOffsetProvider) != 0)
|
|
TextButton(
|
|
onPressed: () => ref.read(mealPlanWeekOffsetProvider.notifier).reset(),
|
|
child: Text(l10n.mealPlanWeekCurrent),
|
|
),
|
|
IconButton(
|
|
tooltip: 'Gå till recept',
|
|
icon: const Icon(Icons.restaurant_menu),
|
|
onPressed: () => context.go('/recipes'),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
...week.days.map(
|
|
(day) => _DayCard(
|
|
date: day,
|
|
locale: locale,
|
|
entry: dashboard.entryForDate(day),
|
|
recipes: recipes,
|
|
isSaving: _savingDate == week.isoDate(day),
|
|
onSelected: (recipeId, servings) => _saveSelection(
|
|
date: week.isoDate(day),
|
|
recipeId: recipeId,
|
|
servings: servings,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
_ShoppingSection(
|
|
dashboard: dashboard,
|
|
plannedCount: plannedCount,
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
String _formatWeekLabel(MealPlanWeek week, String locale) {
|
|
final from = DateFormat('d MMM', locale).format(week.start);
|
|
final to = DateFormat('d MMM y', locale).format(week.end);
|
|
return '$from - $to';
|
|
}
|
|
}
|
|
|
|
class _DayCard extends StatelessWidget {
|
|
final DateTime date;
|
|
final String locale;
|
|
final MealPlanEntry? entry;
|
|
final List<Recipe> recipes;
|
|
final bool isSaving;
|
|
final void Function(int? recipeId, int? servings) onSelected;
|
|
|
|
const _DayCard({
|
|
required this.date,
|
|
required this.locale,
|
|
required this.entry,
|
|
required this.recipes,
|
|
required this.isSaving,
|
|
required this.onSelected,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = context.l10n;
|
|
final theme = Theme.of(context);
|
|
final today = DateTime.now();
|
|
final isToday = today.year == date.year && today.month == date.month && today.day == date.day;
|
|
final selectedValue = entry?.recipe.id.toString() ?? '';
|
|
final recipeServings = entry?.recipe.servings;
|
|
final currentServings = entry?.servings ?? recipeServings;
|
|
|
|
return Card(
|
|
margin: const EdgeInsets.only(bottom: 10),
|
|
color: isToday ? theme.colorScheme.primaryContainer.withValues(alpha: 0.45) : null,
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(12),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
_formatDayLabel(),
|
|
style: theme.textTheme.titleMedium,
|
|
),
|
|
const SizedBox(height: 12),
|
|
DropdownButtonFormField<String>(
|
|
initialValue: selectedValue,
|
|
isExpanded: true,
|
|
decoration: InputDecoration(
|
|
labelText: l10n.mealPlanSelectRecipe,
|
|
border: const OutlineInputBorder(),
|
|
),
|
|
items: [
|
|
DropdownMenuItem<String>(
|
|
value: '',
|
|
child: Text(l10n.mealPlanDayNoRecipe),
|
|
),
|
|
...recipes
|
|
.map(
|
|
(recipe) => DropdownMenuItem<String>(
|
|
value: recipe.id.toString(),
|
|
child: Text(recipe.title),
|
|
),
|
|
)
|
|
.toList(),
|
|
],
|
|
onChanged: isSaving
|
|
? null
|
|
: (value) {
|
|
onSelected(
|
|
int.tryParse((value ?? '').trim()),
|
|
entry?.servings,
|
|
);
|
|
},
|
|
),
|
|
const SizedBox(height: 8),
|
|
Wrap(
|
|
spacing: 8,
|
|
runSpacing: 8,
|
|
crossAxisAlignment: WrapCrossAlignment.center,
|
|
children: [
|
|
if (entry != null)
|
|
TextButton.icon(
|
|
onPressed: isSaving ? null : () => context.push('/recipes/${entry!.recipe.id}'),
|
|
icon: const Icon(Icons.open_in_new),
|
|
label: Text(l10n.mealPlanViewRecipe),
|
|
),
|
|
if (recipeServings != null && entry != null)
|
|
SizedBox(
|
|
width: 220,
|
|
child: DropdownButtonFormField<int>(
|
|
initialValue: currentServings,
|
|
decoration: InputDecoration(
|
|
labelText: l10n.mealPlanServingsLabel,
|
|
border: const OutlineInputBorder(),
|
|
),
|
|
items: _servingsOptions(currentServings, recipeServings)
|
|
.map(
|
|
(servings) => DropdownMenuItem<int>(
|
|
value: servings,
|
|
child: Text(servings.toString()),
|
|
),
|
|
)
|
|
.toList(),
|
|
onChanged: isSaving
|
|
? null
|
|
: (value) {
|
|
if (value == null || entry == null) return;
|
|
onSelected(entry!.recipe.id, value);
|
|
},
|
|
),
|
|
),
|
|
if (entry != null && entry!.servings != null && recipeServings != null)
|
|
OutlinedButton(
|
|
onPressed: isSaving ? null : () => onSelected(entry!.recipe.id, null),
|
|
child: Text('${l10n.mealPlanResetServings} (${recipeServings.toString()})'),
|
|
),
|
|
if (isSaving)
|
|
Text(
|
|
l10n.mealPlanSaving,
|
|
style: theme.textTheme.bodySmall,
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
String _formatDayLabel() {
|
|
final weekday = DateFormat('EEEE', locale).format(date);
|
|
final monthDay = DateFormat('d MMM', locale).format(date);
|
|
return '${_capitalize(weekday)} - $monthDay';
|
|
}
|
|
|
|
List<int> _servingsOptions(int? currentServings, int recipeServings) {
|
|
final upperBound = math.max(12, math.max(currentServings ?? recipeServings, recipeServings));
|
|
return List.generate(upperBound, (index) => index + 1);
|
|
}
|
|
|
|
String _capitalize(String value) {
|
|
if (value.isEmpty) return value;
|
|
return '${value[0].toUpperCase()}${value.substring(1)}';
|
|
}
|
|
}
|
|
|
|
class _ShoppingSection extends StatelessWidget {
|
|
final MealPlanDashboard dashboard;
|
|
final int plannedCount;
|
|
|
|
const _ShoppingSection({
|
|
required this.dashboard,
|
|
required this.plannedCount,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = context.l10n;
|
|
final theme = Theme.of(context);
|
|
|
|
return Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(12),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(l10n.mealPlanShoppingTitle, style: theme.textTheme.titleMedium),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
l10n.mealPlanPlannedRecipes(plannedCount),
|
|
style: theme.textTheme.bodySmall,
|
|
),
|
|
const SizedBox(height: 12),
|
|
if (plannedCount == 0)
|
|
Text(l10n.mealPlanPickRecipeHint)
|
|
else ...[
|
|
_ShoppingSummary(compareItems: dashboard.inventoryCompareItems),
|
|
const SizedBox(height: 8),
|
|
if (dashboard.shoppingItems.isEmpty)
|
|
Text(l10n.mealPlanNoShoppingItems)
|
|
else
|
|
..._buildRows(context),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
List<Widget> _buildRows(BuildContext context) {
|
|
final items = dashboard.shoppingItems
|
|
.map((item) => _EnrichedShoppingItem.from(item, dashboard.inventoryCompareItems))
|
|
.toList()
|
|
..sort((a, b) {
|
|
final statusDiff = a.sortOrder.compareTo(b.sortOrder);
|
|
if (statusDiff != 0) return statusDiff;
|
|
return a.item.name.toLowerCase().compareTo(b.item.name.toLowerCase());
|
|
});
|
|
|
|
return items
|
|
.map(
|
|
(item) => Padding(
|
|
padding: const EdgeInsets.only(bottom: 8),
|
|
child: _ShoppingRow(item: item),
|
|
),
|
|
)
|
|
.toList();
|
|
}
|
|
}
|
|
|
|
class _ShoppingSummary extends StatelessWidget {
|
|
final List<InventoryCompareItem> compareItems;
|
|
|
|
const _ShoppingSummary({required this.compareItems});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (compareItems.isEmpty) {
|
|
return const SizedBox.shrink();
|
|
}
|
|
|
|
final l10n = context.l10n;
|
|
final missing = compareItems.where((item) => item.status == 'missing').length;
|
|
final enough = compareItems.where((item) => item.status == 'enough').length;
|
|
final pantry = compareItems.where((item) => item.status == 'pantry').length;
|
|
final partial = compareItems
|
|
.where((item) => item.status == 'missing' && item.availableQuantity > 0)
|
|
.length;
|
|
|
|
final adjustedMissing = missing - partial;
|
|
|
|
return Wrap(
|
|
spacing: 8,
|
|
runSpacing: 8,
|
|
children: [
|
|
if (adjustedMissing > 0)
|
|
Chip(
|
|
avatar: const Icon(Icons.error_outline, size: 18),
|
|
label: Text(l10n.mealPlanMissingCount(adjustedMissing)),
|
|
),
|
|
if (partial > 0)
|
|
Chip(
|
|
avatar: const Icon(Icons.warning_amber_rounded, size: 18),
|
|
label: Text(l10n.mealPlanPartialCount(partial)),
|
|
),
|
|
if (enough > 0)
|
|
Chip(
|
|
avatar: const Icon(Icons.check_circle_outline, size: 18),
|
|
label: Text(l10n.mealPlanEnoughCount(enough)),
|
|
),
|
|
if (pantry > 0)
|
|
Chip(
|
|
avatar: const Icon(Icons.inventory_2_outlined, size: 18),
|
|
label: Text(l10n.mealPlanPantryCount(pantry)),
|
|
),
|
|
if (adjustedMissing <= 0 && partial <= 0)
|
|
Chip(
|
|
avatar: const Icon(Icons.check_circle_outline, size: 18),
|
|
label: Text(l10n.mealPlanAllAtHome),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ShoppingRow extends StatelessWidget {
|
|
final _EnrichedShoppingItem item;
|
|
|
|
const _ShoppingRow({required this.item});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = context.l10n;
|
|
final theme = Theme.of(context);
|
|
final colorScheme = theme.colorScheme;
|
|
|
|
final (icon, label, background) = switch (item.status) {
|
|
_DisplayStatus.partial => (
|
|
Icons.warning_amber_rounded,
|
|
l10n.mealPlanStatusPartial,
|
|
colorScheme.tertiaryContainer,
|
|
),
|
|
_DisplayStatus.enough => (
|
|
Icons.check_circle_outline,
|
|
l10n.mealPlanStatusEnough,
|
|
colorScheme.secondaryContainer,
|
|
),
|
|
_DisplayStatus.pantry => (
|
|
Icons.inventory_2_outlined,
|
|
l10n.mealPlanStatusPantry,
|
|
colorScheme.surfaceContainerHighest,
|
|
),
|
|
_DisplayStatus.missing => (
|
|
Icons.error_outline,
|
|
l10n.mealPlanStatusMissing,
|
|
colorScheme.errorContainer,
|
|
),
|
|
};
|
|
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10),
|
|
decoration: BoxDecoration(
|
|
color: background,
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Icon(icon, size: 18),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(item.item.name, style: theme.textTheme.bodyLarge),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
item.subtitle(label),
|
|
style: theme.textTheme.bodySmall,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
item.trailingText,
|
|
style: theme.textTheme.labelLarge,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
enum _DisplayStatus { missing, partial, enough, pantry }
|
|
|
|
class _EnrichedShoppingItem {
|
|
final ShoppingItem item;
|
|
final InventoryCompareItem? compareItem;
|
|
final _DisplayStatus status;
|
|
|
|
const _EnrichedShoppingItem({
|
|
required this.item,
|
|
required this.compareItem,
|
|
required this.status,
|
|
});
|
|
|
|
factory _EnrichedShoppingItem.from(
|
|
ShoppingItem item,
|
|
List<InventoryCompareItem> compareItems,
|
|
) {
|
|
final compareItem = compareItems
|
|
.where((compare) => compare.productId == item.productId && compare.unit == item.unit)
|
|
.cast<InventoryCompareItem?>()
|
|
.firstWhere((compare) => compare != null, orElse: () => null);
|
|
|
|
if (compareItem == null) {
|
|
return _EnrichedShoppingItem(
|
|
item: item,
|
|
compareItem: null,
|
|
status: _DisplayStatus.missing,
|
|
);
|
|
}
|
|
|
|
if (compareItem.status == 'pantry') {
|
|
return _EnrichedShoppingItem(
|
|
item: item,
|
|
compareItem: compareItem,
|
|
status: _DisplayStatus.pantry,
|
|
);
|
|
}
|
|
|
|
if (compareItem.availableQuantity >= compareItem.requiredQuantity) {
|
|
return _EnrichedShoppingItem(
|
|
item: item,
|
|
compareItem: compareItem,
|
|
status: _DisplayStatus.enough,
|
|
);
|
|
}
|
|
|
|
if (compareItem.availableQuantity > 0) {
|
|
return _EnrichedShoppingItem(
|
|
item: item,
|
|
compareItem: compareItem,
|
|
status: _DisplayStatus.partial,
|
|
);
|
|
}
|
|
|
|
return _EnrichedShoppingItem(
|
|
item: item,
|
|
compareItem: compareItem,
|
|
status: _DisplayStatus.missing,
|
|
);
|
|
}
|
|
|
|
int get sortOrder => switch (status) {
|
|
_DisplayStatus.missing => 0,
|
|
_DisplayStatus.partial => 1,
|
|
_DisplayStatus.enough => 2,
|
|
_DisplayStatus.pantry => 3,
|
|
};
|
|
|
|
String get trailingText {
|
|
final quantity = switch (status) {
|
|
_DisplayStatus.pantry || _DisplayStatus.enough => 0.0,
|
|
_DisplayStatus.partial => compareItem?.missingQuantity ?? item.quantity,
|
|
_DisplayStatus.missing => item.quantity,
|
|
};
|
|
|
|
if (quantity <= 0) {
|
|
return '-';
|
|
}
|
|
return '${_formatQuantity(quantity)} ${item.unit}';
|
|
}
|
|
|
|
String subtitle(String label) {
|
|
switch (status) {
|
|
case _DisplayStatus.partial:
|
|
final available = compareItem?.availableQuantity ?? 0.0;
|
|
final required = compareItem?.requiredQuantity ?? item.quantity;
|
|
return '$label • ${_formatQuantity(available)} av ${_formatQuantity(required)} ${item.unit} hemma';
|
|
case _DisplayStatus.enough:
|
|
case _DisplayStatus.pantry:
|
|
return label;
|
|
case _DisplayStatus.missing:
|
|
return '$label • ${_formatQuantity(item.quantity)} ${item.unit} behövs';
|
|
}
|
|
}
|
|
|
|
String _formatQuantity(double value) => formatQuantity(value);
|
|
}
|