feat(shopping-list): add shopping list feature with flyer integration
Test Suite / backend-pr-quick (push) Has been skipped
Test Suite / quick-import-pr-quick (push) Has been skipped
Test Suite / backend-full (push) Successful in 5m8s
Test Suite / flutter-quality (push) Failing after 1m41s

This commit introduces a comprehensive shopping list feature with the following key changes:

Backend:
- Added ShoppingListItem model with relations to User, Product, and Category
- Added new fields to FlyerSession for source file metadata
- Added categoryId field to FlyerItem model
- Implemented session source file retrieval endpoint
- Added endpoint for updating flyer session items with category assignment
- Added endpoint for planning flyer selections to shopping list
- Implemented backfillCategoriesMine for AI-assisted category assignment
- Added ShoppingListModule and integrated with FlyerSelectionModule

Frontend:
- Added ShoppingListScreen and navigation route
- Implemented API paths and client methods for shopping list operations
- Added category tree loading for shopping list item creation
- Integrated shopping list functionality in flyer import tab
- Added category backfill trigger in inventory screen
- Updated FlyerImportItem model with categoryId support
- Added methods for updating flyer session items and retrieving source files

Database:
- Added new Prisma migration for flyer source metadata and shopping list items
- Updated schema with new relations and indexes

The shopping list feature allows users to:
1. Plan flyer selections directly to their shopping list
2. View and manage their shopping list items
3. Update flyer session items with proper categorization
4. Retrieve original flyer source files
5. Automatically backfill categories for uncategorized products
This commit is contained in:
Nils-Johan Gynther
2026-05-20 09:07:30 +02:00
parent 996f0d774b
commit a1a2c33427
37 changed files with 1843 additions and 102 deletions
+12 -2
View File
@@ -2,7 +2,7 @@ class AuthApiPaths {
static const login = '/auth/login';
}
class ProductApiPaths {
class ProductApiPaths {
static const list = '/products';
static const mine = '/products/mine';
static const createPrivate = '/products/private';
@@ -13,7 +13,8 @@ class ProductApiPaths {
static const deleted = '/products/deleted';
static const merge = '/products/merge';
static const mergePrivate = '/products/private/merge';
static String updateMineCategory(int id) => '/products/mine/$id/category';
static String updateMineCategory(int id) => '/products/mine/$id/category';
static const backfillMineCategories = '/products/mine/backfill-categories';
static String mergePreview(int sourceProductId, int targetProductId) =>
'/products/merge-preview?sourceProductId=$sourceProductId&targetProductId=$targetProductId';
static String setStatus(int id) => '/products/$id/status';
@@ -42,13 +43,22 @@ class FlyerImportApiPaths {
static const parse = '/flyer-import/parse';
static const latestSession = '/flyer-import/sessions/latest';
static String bySession(int sessionId) => '/flyer-import/sessions/$sessionId';
static String sourceBySession(int sessionId) => '/flyer-import/sessions/$sessionId/source';
static String patchItem(int sessionId, int itemId) => '/flyer-import/sessions/$sessionId/items/$itemId';
}
class FlyerSelectionApiPaths {
static String bySession(int sessionId) => '/flyer-sessions/$sessionId/selections';
static String bulkBySession(int sessionId) => '/flyer-sessions/$sessionId/selections/bulk';
static String planToShoppingListBySession(int sessionId) =>
'/flyer-sessions/$sessionId/selections/plan-to-shopping-list';
static const open = '/flyer-selections/open';
}
class ShoppingListApiPaths {
static const items = '/shopping-list/items';
static String updateStatus(int itemId) => '/shopping-list/items/$itemId/status';
}
class HelpTextApiPaths {
static String byKey(String key) => '/help-texts/${Uri.encodeComponent(key)}';
+27 -17
View File
@@ -21,17 +21,19 @@ import '../../features/inventory/presentation/consume_inventory_screen.dart';
import '../../features/inventory/presentation/consumption_history_screen.dart';
import '../../features/meal_plan/presentation/meal_plan_screen.dart';
import '../../features/pantry/presentation/pantry_screen.dart';
import '../../features/import/presentation/import_screen.dart';
import '../../features/admin/presentation/admin_screen.dart';
import '../../features/import/presentation/import_screen.dart';
import '../../features/shopping_list/presentation/shopping_list_screen.dart';
import '../../features/admin/presentation/admin_screen.dart';
int? _shellBranchIndexForPath(String path) {
if (path.startsWith('/recipes')) return 0;
if (path.startsWith('/inventory')) return 1;
if (path.startsWith('/matsedel')) return 2;
if (path.startsWith('/baslager')) return 3;
if (path.startsWith('/import')) return 4;
if (path.startsWith('/profile')) return 5;
if (path.startsWith('/admin')) return 6;
if (path.startsWith('/import')) return 4;
if (path.startsWith('/inkopslista')) return 5;
if (path.startsWith('/profile')) return 6;
if (path.startsWith('/admin')) return 7;
return null;
}
@@ -242,18 +244,26 @@ final appRouterProvider = Provider<GoRouter>((ref) {
),
],
),
StatefulShellBranch(
routes: [
GoRoute(
path: '/import',
builder: (context, state) => const ImportScreen(),
),
],
),
StatefulShellBranch(
routes: [
GoRoute(
path: '/profile',
StatefulShellBranch(
routes: [
GoRoute(
path: '/import',
builder: (context, state) => const ImportScreen(),
),
],
),
StatefulShellBranch(
routes: [
GoRoute(
path: '/inkopslista',
builder: (context, state) => const ShoppingListScreen(),
),
],
),
StatefulShellBranch(
routes: [
GoRoute(
path: '/profile',
builder: (context, state) => const ProfileScreen(),
),
],
+13 -7
View File
@@ -49,13 +49,19 @@ class AppShell extends ConsumerWidget {
icon: Icons.storefront_outlined,
label: 'Baslager',
),
_AppDestination(
path: '/import',
title: 'Importera',
icon: Icons.upload_file_outlined,
label: 'Importera',
),
];
_AppDestination(
path: '/import',
title: 'Importera',
icon: Icons.upload_file_outlined,
label: 'Importera',
),
_AppDestination(
path: '/inkopslista',
title: 'Inköpslista',
icon: Icons.shopping_cart_outlined,
label: 'Inköpslista',
),
];
List<_AppDestination> _destinations() => _baseDestinations;
@@ -7,6 +7,7 @@ import 'dart:developer' as developer;
import '../../../core/api/api_paths.dart';
import '../../../core/api/api_exception.dart';
import '../domain/flyer_import_item.dart';
import '../domain/flyer_import_result.dart';
import '../domain/help_text_content.dart';
import '../domain/quick_import_result.dart';
@@ -282,6 +283,95 @@ class ImportRepository {
return parsed.cast<Map<String, dynamic>>();
}
Future<Map<String, dynamic>> planFlyerSelectionsToShoppingList({
required int sessionId,
required List<int> itemIds,
String? token,
}) async {
final uri = Uri.parse('$_baseUrl${FlyerSelectionApiPaths.planToShoppingListBySession(sessionId)}');
final response = await _client.post(
uri,
headers: {
'Content-Type': 'application/json',
if (token != null) 'Authorization': 'Bearer $token',
},
body: jsonEncode({'itemIds': itemIds}),
);
if (response.statusCode < 200 || response.statusCode >= 300) {
throw ApiException(
type: _mapStatusCodeToErrorType(response.statusCode),
message: 'Kunde inte planera till inköpslista: ${response.body}',
statusCode: response.statusCode,
);
}
final parsed = _parseResponse(response);
if (parsed is! Map<String, dynamic>) return const {};
return parsed;
}
Future<FlyerImportItem> updateFlyerSessionItem({
required int sessionId,
required int itemId,
required String rawName,
required int? categoryId,
String? token,
}) async {
final uri = Uri.parse('$_baseUrl${FlyerImportApiPaths.patchItem(sessionId, itemId)}');
final response = await _client.patch(
uri,
headers: {
'Content-Type': 'application/json',
if (token != null) 'Authorization': 'Bearer $token',
},
body: jsonEncode({
'rawName': rawName,
'categoryId': categoryId,
}),
);
if (response.statusCode < 200 || response.statusCode >= 300) {
throw ApiException(
type: _mapStatusCodeToErrorType(response.statusCode),
message: 'Kunde inte uppdatera flyer-rad: ${response.body}',
statusCode: response.statusCode,
);
}
final parsed = _parseResponse(response);
if (parsed is! Map<String, dynamic>) {
throw ApiException(
type: ApiErrorType.unknown,
message: 'Felaktigt svar vid uppdatering av flyer-rad.',
);
}
return FlyerImportItem.fromJson(parsed);
}
Future<Uint8List> getFlyerSourceBytes({
required int sessionId,
String? token,
}) async {
final response = await _client.get(
Uri.parse('$_baseUrl${FlyerImportApiPaths.sourceBySession(sessionId)}'),
headers: {
if (token != null) 'Authorization': 'Bearer $token',
},
);
if (response.statusCode < 200 || response.statusCode >= 300) {
throw ApiException(
type: _mapStatusCodeToErrorType(response.statusCode),
message: 'Kunde inte hämta flyerkälla: ${response.body}',
statusCode: response.statusCode,
);
}
return response.bodyBytes;
}
/// Upload a file (PDF or image) for recipe extraction.
///
/// [bytes] — raw file bytes from file_picker.
@@ -2,7 +2,8 @@ class FlyerImportItem {
final int? flyerItemId;
final String rawName;
final String normalizedName;
final String? category;
final String? category;
final int? categoryId;
final double? price;
final String? priceUnit;
final String? offerText;
@@ -21,7 +22,8 @@ class FlyerImportItem {
required this.flyerItemId,
required this.rawName,
required this.normalizedName,
this.category,
this.category,
this.categoryId,
this.price,
this.priceUnit,
this.offerText,
@@ -42,7 +44,8 @@ class FlyerImportItem {
flyerItemId: (json['flyerItemId'] as num?)?.toInt(),
rawName: json['rawName'] as String? ?? '',
normalizedName: json['normalizedName'] as String? ?? '',
category: json['category'] as String?,
category: json['category'] as String?,
categoryId: (json['categoryId'] as num?)?.toInt(),
price: (json['price'] as num?)?.toDouble(),
priceUnit: json['priceUnit'] as String?,
offerText: json['offerText'] as String?,
@@ -65,6 +68,7 @@ class FlyerImportItem {
'rawName': rawName,
'normalizedName': normalizedName,
'category': category,
'categoryId': categoryId,
'price': price,
'priceUnit': priceUnit,
'offerText': offerText,
@@ -80,4 +84,31 @@ class FlyerImportItem {
'matchConfidence': matchConfidence,
};
}
FlyerImportItem copyWith({
String? rawName,
String? category,
int? categoryId,
}) {
return FlyerImportItem(
flyerItemId: flyerItemId,
rawName: rawName ?? this.rawName,
normalizedName: normalizedName,
category: category ?? this.category,
categoryId: categoryId ?? this.categoryId,
price: price,
priceUnit: priceUnit,
offerText: offerText,
isOffer: isOffer,
offerLimitText: offerLimitText,
comparisonPrice: comparisonPrice,
comparisonUnit: comparisonUnit,
parseConfidence: parseConfidence,
parseReasons: parseReasons,
matchedProductId: matchedProductId,
matchedProductName: matchedProductName,
matchedVia: matchedVia,
matchConfidence: matchConfidence,
);
}
}
@@ -4,11 +4,19 @@ class FlyerImportResult {
final int? sessionId;
final List<FlyerImportItem> items;
final List<String> warnings;
final bool sourceAvailable;
final String? sourceFileName;
final String? sourceMimeType;
final int? sourceFileSize;
FlyerImportResult({
required this.sessionId,
required this.items,
required this.warnings,
required this.sourceAvailable,
this.sourceFileName,
this.sourceMimeType,
this.sourceFileSize,
});
factory FlyerImportResult.fromJson(Map<String, dynamic> json) {
@@ -24,6 +32,10 @@ class FlyerImportResult {
.map((item) => FlyerImportItem.fromJson(item as Map<String, dynamic>))
.toList(),
warnings: warnings,
sourceAvailable: json['sourceAvailable'] == true,
sourceFileName: json['sourceFileName'] as String?,
sourceMimeType: json['sourceMimeType'] as String?,
sourceFileSize: (json['sourceFileSize'] as num?)?.toInt(),
);
}
@@ -32,6 +44,10 @@ class FlyerImportResult {
'sessionId': sessionId,
'items': items.map((item) => item.toJson()).toList(),
'warnings': warnings,
'sourceAvailable': sourceAvailable,
'sourceFileName': sourceFileName,
'sourceMimeType': sourceMimeType,
'sourceFileSize': sourceFileSize,
};
}
}
@@ -1,9 +1,16 @@
import 'package:file_picker/file_picker.dart';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/api/api_paths.dart';
import '../../../core/api/api_providers.dart';
import '../../../core/ui/category_then_product_picker.dart';
import '../../admin/domain/admin_category_node.dart';
import '../../../core/utils/pdf_opener.dart';
import '../../auth/data/auth_providers.dart';
import '../../shopping_list/data/shopping_list_providers.dart';
import '../data/flyer_import_session.dart';
import '../data/import_providers.dart';
import '../domain/flyer_import_item.dart';
@@ -21,15 +28,38 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
bool _isLoading = false;
bool _isSaving = false;
PlatformFile? _pickedFile;
Uint8List? _restoredSourceBytes;
List<AdminCategoryNode> _categoryTree = const [];
FlyerImportResult? _result;
final Map<int, bool> _selected = {};
@override
void initState() {
super.initState();
_loadCategoryTree();
_restoreSession();
}
Future<void> _loadCategoryTree() async {
try {
final token = await ref.read(authStateProvider.future);
final api = ref.read(apiClientProvider);
final categoryData = await api.getJson(CategoryApiPaths.tree, token: token);
final categoryList = categoryData is List<dynamic>
? categoryData
: (categoryData is Map<String, dynamic> && categoryData['items'] is List<dynamic>)
? categoryData['items'] as List<dynamic>
: const <dynamic>[];
final tree = categoryList
.map((e) => AdminCategoryNode.fromJson(Map<String, dynamic>.from(e as Map)))
.toList();
if (!mounted) return;
setState(() => _categoryTree = tree);
} catch (_) {
// Kategoriträdet är valfritt för att visa listan.
}
}
Future<void> _restoreSession() async {
final notifier = ref.read(flyerImportSessionProvider.notifier);
await notifier.restore();
@@ -53,10 +83,12 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
: session.selected;
setState(() {
_result = serverResult;
_restoredSourceBytes = null;
_selected
..clear()
..addAll(selected);
});
await _loadRestoredSourceIfNeeded(serverResult, token);
notifier.setImportedResult(
result: serverResult,
selected: selected,
@@ -78,10 +110,12 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
};
setState(() {
_result = latest;
_restoredSourceBytes = null;
_selected
..clear()
..addAll(selected);
});
await _loadRestoredSourceIfNeeded(latest, token);
notifier.setImportedResult(
result: latest,
selected: selected,
@@ -100,6 +134,7 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
if (!mounted || session?.result == null) return;
setState(() {
_result = session!.result;
_restoredSourceBytes = null;
_selected
..clear()
..addAll(session.selected);
@@ -107,6 +142,20 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
}
}
Future<void> _loadRestoredSourceIfNeeded(FlyerImportResult result, String? token) async {
if (result.sessionId == null || result.sourceAvailable != true) return;
if (_pickedFile?.bytes != null) return;
try {
final repo = ref.read(importRepositoryProvider);
final bytes = await repo.getFlyerSourceBytes(sessionId: result.sessionId!, token: token);
if (!mounted) return;
setState(() => _restoredSourceBytes = bytes);
} catch (_) {
if (!mounted) return;
setState(() => _restoredSourceBytes = null);
}
}
Future<void> _pickFile() async {
final result = await FilePicker.pickFiles(
type: FileType.custom,
@@ -138,6 +187,7 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
}
setState(() {
_result = parsed;
_restoredSourceBytes = null;
_selected
..clear()
..addAll(selected);
@@ -159,10 +209,12 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
if (result?.sessionId == null) return;
final itemsToSave = <Map<String, dynamic>>[];
final selectedItemIds = <int>[];
for (var i = 0; i < result!.items.length; i++) {
final item = result.items[i];
final isSelected = _selected[i] == true;
if (!isSelected || item.flyerItemId == null) continue;
selectedItemIds.add(item.flyerItemId!);
itemsToSave.add({
'itemId': item.flyerItemId,
'plannedQuantity': 1,
@@ -187,9 +239,23 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
items: itemsToSave,
token: token,
);
final shopping = await repo.planFlyerSelectionsToShoppingList(
sessionId: result.sessionId!,
itemIds: selectedItemIds,
token: token,
);
if (!mounted) return;
final created = (shopping['created'] as num?)?.toInt() ?? 0;
final updated = (shopping['updated'] as num?)?.toInt() ?? 0;
ref.invalidate(shoppingListItemsProvider);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${saved.length} varor planerade.')),
SnackBar(
content: Text('${saved.length} planerade. Inköpslista: $created tillagda, $updated uppdaterade.'),
action: SnackBarAction(
label: 'Öppna',
onPressed: () => context.go('/inkopslista'),
),
),
);
} catch (e) {
if (mounted) showErrorDialog(context, 'Kunde inte planera varor: $e');
@@ -198,6 +264,141 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
}
}
Future<void> _editItem(int index, FlyerImportItem item) async {
final sessionId = _result?.sessionId;
final itemId = item.flyerItemId;
if (sessionId == null || itemId == null) return;
final nameController = TextEditingController(text: item.rawName);
int? selectedCategoryId = item.categoryId;
String? selectedCategoryPath = item.category;
final payload = await showDialog<({String name, int? categoryId, String? categoryPath})>(
context: context,
builder: (context) {
return StatefulBuilder(
builder: (context, setLocalState) {
return AlertDialog(
title: const Text('Redigera rad'),
content: SizedBox(
width: 420,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: nameController,
decoration: const InputDecoration(
labelText: 'Namn',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
Text(
selectedCategoryPath == null || selectedCategoryPath!.isEmpty
? 'Ingen kategori vald'
: selectedCategoryPath!,
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: [
OutlinedButton.icon(
onPressed: _categoryTree.isEmpty
? null
: () async {
final selected = await CategoryThenProductPicker.showCategorySheet(
context,
categoryTree: _categoryTree,
preselectedCategoryId: selectedCategoryId,
);
if (selected == null) return;
setLocalState(() {
selectedCategoryId = selected.id;
selectedCategoryPath = selected.path;
});
},
icon: const Icon(Icons.category_outlined),
label: const Text('Välj kategori'),
),
TextButton(
onPressed: () {
setLocalState(() {
selectedCategoryId = null;
selectedCategoryPath = null;
});
},
child: const Text('Rensa'),
),
],
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Avbryt'),
),
FilledButton(
onPressed: () {
Navigator.of(context).pop((
name: nameController.text.trim(),
categoryId: selectedCategoryId,
categoryPath: selectedCategoryPath,
));
},
child: const Text('Spara'),
),
],
);
},
);
},
);
nameController.dispose();
if (payload == null || payload.name.isEmpty) return;
try {
final token = await ref.read(authStateProvider.future);
final repo = ref.read(importRepositoryProvider);
final updated = await repo.updateFlyerSessionItem(
sessionId: sessionId,
itemId: itemId,
rawName: payload.name,
categoryId: payload.categoryId,
token: token,
);
if (!mounted) return;
final result = _result;
if (result == null) return;
final nextItems = [...result.items];
nextItems[index] = updated;
final nextResult = FlyerImportResult(
sessionId: result.sessionId,
items: nextItems,
warnings: result.warnings,
sourceAvailable: result.sourceAvailable,
sourceFileName: result.sourceFileName,
sourceMimeType: result.sourceMimeType,
sourceFileSize: result.sourceFileSize,
);
setState(() {
_result = nextResult;
});
ref.read(flyerImportSessionProvider.notifier).setImportedResult(
result: nextResult,
selected: Map<int, bool>.from(_selected),
fileName: _pickedFile?.name ?? result.sourceFileName,
);
} catch (e) {
if (!mounted) return;
showErrorDialog(context, 'Kunde inte uppdatera rad: $e');
}
}
String _formatPrice(double? price, String? unit) {
if (price == null) return '';
final raw = price.toStringAsFixed(2).replaceAll('.', ',');
@@ -320,10 +521,10 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
Widget _buildFlyerPreview(ThemeData theme) {
final file = _pickedFile;
final bytes = file?.bytes;
final bytes = file?.bytes ?? _restoredSourceBytes;
if (bytes == null) return const SizedBox.shrink();
final filename = file?.name ?? '';
final filename = file?.name ?? _result?.sourceFileName ?? '';
final fallbackExt = filename.contains('.') ? filename.split('.').last : '';
final ext = (file?.extension ?? fallbackExt).toLowerCase();
final isImage = ['png', 'jpg', 'jpeg', 'webp', 'bmp'].contains(ext);
@@ -339,7 +540,7 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
color: theme.colorScheme.primary,
),
title: const Text('Flyerförhandsvisning'),
subtitle: Text(file?.name ?? ''),
subtitle: Text(filename),
trailing: isImage
? null
: OutlinedButton.icon(
@@ -449,11 +650,17 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
setState(() => _selected[index] = checked);
ref.read(flyerImportSessionProvider.notifier).setSelected(index, checked);
},
title: Row(
children: [
Expanded(child: Text(item.rawName)),
const SizedBox(width: 8),
_buildQualityBadge(item, theme),
title: Row(
children: [
Expanded(child: Text(item.rawName)),
IconButton(
tooltip: 'Redigera',
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.edit_outlined, size: 18),
onPressed: () => _editItem(index, item),
),
const SizedBox(width: 8),
_buildQualityBadge(item, theme),
const SizedBox(width: 8),
_buildOfferBadge(item, theme),
],
@@ -461,8 +668,10 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (priceText.isNotEmpty) Text('Pris: $priceText'),
if (comparisonText.isNotEmpty) Text('Jämförpris: $comparisonText'),
if (priceText.isNotEmpty) Text('Pris: $priceText'),
if ((item.category ?? '').trim().isNotEmpty)
Text('Kategori: ${item.category}'),
if (comparisonText.isNotEmpty) Text('Jämförpris: $comparisonText'),
if (limitText != null && limitText.isNotEmpty)
Text(
'Begränsning: $limitText',
@@ -249,6 +249,7 @@ class _CreateInventoryScreenState
setState(() => _saving = true);
try {
final token = await ref.read(authStateProvider.future);
await _syncSelectedProductCategory(token);
await ref
.read(pantryRepositoryProvider)
.createPantryItem(
@@ -527,4 +528,3 @@ class _CreateInventoryScreenState
);
}
}
@@ -2,11 +2,14 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/api/api_error_mapper.dart';
import '../../../core/l10n/l10n.dart';
import '../../../core/api/api_error_mapper.dart';
import '../../../core/api/api_paths.dart';
import '../../../core/api/api_providers.dart';
import '../../../core/l10n/l10n.dart';
import '../../../core/ui/async_state_views.dart';
import '../../../core/utils/display_labels.dart';
import '../../auth/data/auth_providers.dart';
import '../../auth/data/auth_providers.dart';
import '../../pantry/data/pantry_providers.dart';
import '../domain/inventory_item.dart';
import '../data/inventory_providers.dart';
import 'swipeable_inventory_tile.dart';
@@ -18,9 +21,30 @@ class InventoryScreen extends ConsumerStatefulWidget {
ConsumerState<InventoryScreen> createState() => _InventoryScreenState();
}
class _InventoryScreenState extends ConsumerState<InventoryScreen> {
class _InventoryScreenState extends ConsumerState<InventoryScreen> {
final Set<int> _selectedIds = <int>{};
static const _sortByDisplayedCategory = 'l1CategoryAsc';
static const _sortByDisplayedCategory = 'l1CategoryAsc';
bool _backfillTriggered = false;
@override
void initState() {
super.initState();
_triggerCategoryBackfill();
}
Future<void> _triggerCategoryBackfill() async {
if (_backfillTriggered) return;
_backfillTriggered = true;
try {
final token = await ref.read(authStateProvider.future);
final api = ref.read(apiClientProvider);
await api.postJson(ProductApiPaths.backfillMineCategories, token: token);
ref.invalidate(inventoryProvider);
ref.invalidate(pantryProvider);
} catch (_) {
// Ignorera fel här för att inte blockera vyn.
}
}
static const _locationOptions = <String>['', 'Kyl', 'Frys', 'Skafferi'];
@@ -0,0 +1,18 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/api/api_providers.dart';
import '../../../core/api/guarded_api_call.dart';
import '../../auth/data/auth_providers.dart';
import '../domain/shopping_list_item.dart';
import 'shopping_list_repository.dart';
final shoppingListRepositoryProvider = Provider<ShoppingListRepository>((ref) {
return ShoppingListRepository(ref.watch(apiClientProvider));
});
final shoppingListItemsProvider = FutureProvider<List<ShoppingListItem>>((ref) async {
final token = await ref.watch(authStateProvider.future);
return guardedApiCall(ref, () {
return ref.read(shoppingListRepositoryProvider).fetchOpenItems(token: token);
});
});
@@ -0,0 +1,60 @@
import '../../../core/api/api_client.dart';
import '../../../core/api/api_exception.dart';
import '../../../core/api/api_paths.dart';
import '../domain/shopping_list_item.dart';
class ShoppingListRepository {
final ApiClient _api;
const ShoppingListRepository(this._api);
Future<List<ShoppingListItem>> fetchOpenItems({String? token}) async {
try {
final data = await _api.getJson(ShoppingListApiPaths.items, token: token);
if (data is! List) {
throw const ApiException(
type: ApiErrorType.unknown,
message: 'Ogiltigt svar från servern.',
);
}
return data
.map((item) => ShoppingListItem.fromJson(item as Map<String, dynamic>))
.toList();
} on ApiException {
rethrow;
} catch (_) {
throw const ApiException(
type: ApiErrorType.network,
message: 'Kunde inte hämta inköpslistan.',
);
}
}
Future<ShoppingListItem> updateStatus({
required int itemId,
required bool checked,
String? token,
}) async {
try {
final data = await _api.patchJson(
ShoppingListApiPaths.updateStatus(itemId),
body: {'checked': checked},
token: token,
);
if (data is! Map<String, dynamic>) {
throw const ApiException(
type: ApiErrorType.unknown,
message: 'Ogiltigt svar från servern.',
);
}
return ShoppingListItem.fromJson(data);
} on ApiException {
rethrow;
} catch (_) {
throw const ApiException(
type: ApiErrorType.network,
message: 'Kunde inte uppdatera inköpsrad.',
);
}
}
}
@@ -0,0 +1,40 @@
class ShoppingListItem {
final int id;
final String name;
final int? productId;
final int? categoryId;
final double? quantity;
final String? unit;
final String status;
const ShoppingListItem({
required this.id,
required this.name,
required this.productId,
required this.categoryId,
required this.quantity,
required this.unit,
required this.status,
});
factory ShoppingListItem.fromJson(Map<String, dynamic> json) {
return ShoppingListItem(
id: (json['id'] as num).toInt(),
name: (json['name'] ?? '').toString(),
productId: (json['productId'] as num?)?.toInt(),
categoryId: (json['categoryId'] as num?)?.toInt(),
quantity: (json['quantity'] as num?)?.toDouble(),
unit: json['unit'] as String?,
status: (json['status'] ?? 'open').toString(),
);
}
String get quantityLabel {
if (quantity == null) return '';
final text = quantity == quantity!.roundToDouble()
? quantity!.toStringAsFixed(0)
: quantity!.toStringAsFixed(2).replaceAll('.', ',');
if (unit == null || unit!.trim().isEmpty) return text;
return '$text ${unit!.trim()}';
}
}
@@ -0,0 +1,99 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/api/api_error_mapper.dart';
import '../../../core/ui/async_state_views.dart';
import '../../auth/data/auth_providers.dart';
import '../data/shopping_list_providers.dart';
import '../domain/shopping_list_item.dart';
class ShoppingListScreen extends ConsumerWidget {
const ShoppingListScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final asyncItems = ref.watch(shoppingListItemsProvider);
return asyncItems.when(
loading: () => const LoadingStateView(label: 'Laddar inköpslista...'),
error: (error, _) => ErrorStateView(
message: mapErrorToUserMessage(error, context),
onRetry: () => ref.invalidate(shoppingListItemsProvider),
),
data: (items) {
if (items.isEmpty) {
return const EmptyStateView(
title: 'Inköpslistan är tom',
description: 'Planerade flyer-varor hamnar här.',
);
}
return ListView.separated(
padding: const EdgeInsets.all(12),
itemCount: items.length,
separatorBuilder: (_, __) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final item = items[index];
return _ShoppingListTile(item: item);
},
);
},
);
}
}
class _ShoppingListTile extends ConsumerStatefulWidget {
final ShoppingListItem item;
const _ShoppingListTile({required this.item});
@override
ConsumerState<_ShoppingListTile> createState() => _ShoppingListTileState();
}
class _ShoppingListTileState extends ConsumerState<_ShoppingListTile> {
bool _saving = false;
Future<void> _checkOff() async {
if (_saving) return;
setState(() => _saving = true);
try {
final token = await ref.read(authStateProvider.future);
await ref.read(shoppingListRepositoryProvider).updateStatus(
itemId: widget.item.id,
checked: true,
token: token,
);
ref.invalidate(shoppingListItemsProvider);
} catch (error) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
buildCopyableErrorSnackBar(context, mapErrorToUserMessage(error, context)),
);
} finally {
if (mounted) setState(() => _saving = false);
}
}
@override
Widget build(BuildContext context) {
final item = widget.item;
return Card(
child: ListTile(
leading: _saving
? const SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Checkbox(
value: false,
onChanged: (_) => _checkOff(),
),
title: Text(item.name),
subtitle: item.quantityLabel.isEmpty ? null : Text(item.quantityLabel),
),
);
}
}
@@ -31,6 +31,7 @@ void main() {
),
],
warnings: const [],
sourceAvailable: false,
),
selected: const {0: true},
fileName: 'flyer.pdf',
@@ -0,0 +1,41 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:recipe_flutter/features/inventory/domain/inventory_item.dart';
void main() {
group('InventoryItem.l1Category', () {
test('uses first category level from category path', () {
final item = InventoryItem.fromJson({
'id': 1,
'productId': 10,
'quantity': 1,
'unit': 'st',
'product': {
'name': 'Tomat',
'categoryRef': {
'name': 'Tomater',
'parent': {
'name': 'Grönsaker',
'parent': {'name': 'Mat'},
},
},
},
});
expect(item.categoryPath, 'Mat > Grönsaker > Tomater');
expect(item.l1Category, 'Mat');
});
test('falls back to Övrigt when category path is missing', () {
final item = InventoryItem.fromJson({
'id': 1,
'productId': 10,
'quantity': 1,
'unit': 'st',
'product': {'name': 'Okänd', 'categoryRef': null},
});
expect(item.categoryPath, isNull);
expect(item.l1Category, 'Övrigt');
});
});
}