feat(shopping-list): add shopping list feature with flyer integration
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:
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user