feat(inventory): add inventory preview functionality and related models
This commit is contained in:
@@ -8,6 +8,7 @@ import '../../../core/ui/async_state_views.dart';
|
||||
import '../../auth/data/auth_providers.dart';
|
||||
import '../data/recipe_providers.dart';
|
||||
import '../domain/recipe.dart';
|
||||
import '../domain/inventory_preview.dart';
|
||||
|
||||
class RecipeDetailScreen extends ConsumerWidget {
|
||||
final int recipeId;
|
||||
@@ -182,9 +183,208 @@ class _RecipeBody extends StatelessWidget {
|
||||
style: theme.textTheme.bodyMedium
|
||||
?.copyWith(height: 1.6)),
|
||||
],
|
||||
_InventoryPreviewSection(recipeId: recipe.id),
|
||||
const SizedBox(height: 40),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InventoryPreviewSection extends ConsumerStatefulWidget {
|
||||
final int recipeId;
|
||||
|
||||
const _InventoryPreviewSection({required this.recipeId});
|
||||
|
||||
@override
|
||||
ConsumerState<_InventoryPreviewSection> createState() =>
|
||||
_InventoryPreviewSectionState();
|
||||
}
|
||||
|
||||
class _InventoryPreviewSectionState
|
||||
extends ConsumerState<_InventoryPreviewSection> {
|
||||
bool _loaded = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
children: [
|
||||
Text('Vad saknas?', style: theme.textTheme.titleMedium),
|
||||
const Spacer(),
|
||||
if (!_loaded)
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: () => setState(() => _loaded = true),
|
||||
icon: const Icon(Icons.search, size: 16),
|
||||
label: const Text('Kontrollera inventarie'),
|
||||
),
|
||||
if (_loaded)
|
||||
IconButton(
|
||||
tooltip: 'Uppdatera',
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: () {
|
||||
ref.invalidate(inventoryPreviewProvider(widget.recipeId));
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_loaded) _InventoryPreviewResults(recipeId: widget.recipeId),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InventoryPreviewResults extends ConsumerWidget {
|
||||
final int recipeId;
|
||||
|
||||
const _InventoryPreviewResults({required this.recipeId});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final previewAsync = ref.watch(inventoryPreviewProvider(recipeId));
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return previewAsync.when(
|
||||
loading: () => const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 16),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
error: (error, _) => Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Text(
|
||||
mapErrorToUserMessage(error, context),
|
||||
style: TextStyle(color: theme.colorScheme.error),
|
||||
),
|
||||
),
|
||||
data: (preview) {
|
||||
final summary = preview.summary;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
_SummaryChips(summary: summary),
|
||||
const SizedBox(height: 12),
|
||||
...preview.ingredients.map(
|
||||
(ing) => _IngredientPreviewRow(ingredient: ing),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SummaryChips extends StatelessWidget {
|
||||
final PreviewSummary summary;
|
||||
|
||||
const _SummaryChips({required this.summary});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final cs = theme.colorScheme;
|
||||
return Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 4,
|
||||
children: [
|
||||
if (summary.canCookExactly)
|
||||
Chip(
|
||||
avatar: Icon(Icons.check_circle,
|
||||
color: cs.onPrimary, size: 16),
|
||||
label: const Text('Kan lagas!'),
|
||||
backgroundColor: cs.primary,
|
||||
labelStyle: TextStyle(color: cs.onPrimary),
|
||||
)
|
||||
else
|
||||
Chip(
|
||||
avatar: Icon(Icons.warning_amber,
|
||||
color: cs.onErrorContainer, size: 16),
|
||||
label: Text('Saknar ${summary.missingCount} ingrediens'
|
||||
'${summary.missingCount == 1 ? '' : 'er'}'),
|
||||
backgroundColor: cs.errorContainer,
|
||||
labelStyle: TextStyle(color: cs.onErrorContainer),
|
||||
),
|
||||
if (summary.unitMismatchCount > 0)
|
||||
Chip(
|
||||
avatar: Icon(Icons.swap_horiz,
|
||||
color: cs.onTertiaryContainer, size: 16),
|
||||
label: Text('${summary.unitMismatchCount} enhetsmismatch'),
|
||||
backgroundColor: cs.tertiaryContainer,
|
||||
labelStyle: TextStyle(color: cs.onTertiaryContainer),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _IngredientPreviewRow extends StatelessWidget {
|
||||
final IngredientPreview ingredient;
|
||||
|
||||
const _IngredientPreviewRow({required this.ingredient});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final cs = theme.colorScheme;
|
||||
|
||||
final (icon, color) = switch (ingredient.status) {
|
||||
IngredientStatus.enough => (Icons.check_circle_outline, cs.primary),
|
||||
IngredientStatus.unitMismatch => (
|
||||
Icons.swap_horiz,
|
||||
cs.tertiary,
|
||||
),
|
||||
IngredientStatus.missing => (Icons.cancel_outlined, cs.error),
|
||||
};
|
||||
|
||||
String _fmt(double v) =>
|
||||
v == v.truncateToDouble() ? v.toInt().toString() : v.toString();
|
||||
|
||||
final requiredStr =
|
||||
'${_fmt(ingredient.requiredQuantity)} ${ingredient.requiredUnit}'.trim();
|
||||
final availableStr =
|
||||
'${_fmt(ingredient.availableQuantity)} ${ingredient.requiredUnit}'.trim();
|
||||
|
||||
final subtitle = switch (ingredient.status) {
|
||||
IngredientStatus.enough => 'Tillgängligt: $availableStr',
|
||||
IngredientStatus.missing => ingredient.availableQuantity > 0
|
||||
? 'Saknar ${_fmt(ingredient.missingQuantity)} ${ingredient.requiredUnit} '
|
||||
'(har $availableStr)'
|
||||
: 'Saknas helt',
|
||||
IngredientStatus.unitMismatch =>
|
||||
'Annan enhet i lager – kontrollera manuellt',
|
||||
};
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(icon, color: color, size: 18),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'${ingredient.productName}'
|
||||
'${ingredient.note != null ? ' (${ingredient.note})' : ''}'
|
||||
' – $requiredStr',
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
Text(
|
||||
subtitle,
|
||||
style: theme.textTheme.bodySmall
|
||||
?.copyWith(color: color),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user