feat: enhance inventory and pantry features with filtering, sorting, and error handling improvements
This commit is contained in:
@@ -7,15 +7,36 @@ import '../domain/inventory_item.dart';
|
||||
import '../domain/inventory_consumption.dart';
|
||||
import 'inventory_repository.dart';
|
||||
|
||||
class InventoryQuery {
|
||||
final String location;
|
||||
final String sort;
|
||||
|
||||
const InventoryQuery({required this.location, required this.sort});
|
||||
}
|
||||
|
||||
final inventoryLocationFilterProvider = StateProvider<String>((ref) => '');
|
||||
final inventorySortFilterProvider = StateProvider<String>((ref) => '');
|
||||
|
||||
final inventoryQueryProvider = Provider<InventoryQuery>((ref) {
|
||||
final location = ref.watch(inventoryLocationFilterProvider);
|
||||
final sort = ref.watch(inventorySortFilterProvider);
|
||||
return InventoryQuery(location: location, sort: sort);
|
||||
});
|
||||
|
||||
final inventoryRepositoryProvider = Provider<InventoryRepository>((ref) {
|
||||
return InventoryRepository(ref.watch(apiClientProvider));
|
||||
});
|
||||
|
||||
final inventoryProvider = FutureProvider<List<InventoryItem>>((ref) async {
|
||||
final token = await ref.watch(authStateProvider.future);
|
||||
final query = ref.watch(inventoryQueryProvider);
|
||||
return guardedApiCall(
|
||||
ref,
|
||||
() => ref.read(inventoryRepositoryProvider).fetchInventory(token: token),
|
||||
() => ref.read(inventoryRepositoryProvider).fetchInventory(
|
||||
location: query.location,
|
||||
sort: query.sort,
|
||||
token: token,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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 '../data/inventory_providers.dart';
|
||||
import '../domain/inventory_consumption.dart';
|
||||
@@ -25,7 +26,7 @@ class ConsumptionHistoryScreen extends ConsumerWidget {
|
||||
body: historyAsync.when(
|
||||
loading: () => const LoadingStateView(label: 'Laddar historik...'),
|
||||
error: (e, _) => ErrorStateView(
|
||||
message: e.toString(),
|
||||
message: mapErrorToUserMessage(e),
|
||||
onRetry: () => ref.invalidate(consumptionHistoryProvider(itemId)),
|
||||
),
|
||||
data: (history) {
|
||||
|
||||
@@ -36,7 +36,7 @@ class InventoryDetailScreen extends ConsumerWidget {
|
||||
body: itemAsync.when(
|
||||
loading: () => const LoadingStateView(label: 'Laddar...'),
|
||||
error: (e, _) => ErrorStateView(
|
||||
message: e.toString(),
|
||||
message: mapErrorToUserMessage(e),
|
||||
onRetry: () => ref.invalidate(inventoryDetailProvider(itemId)),
|
||||
),
|
||||
data: (item) => ListView(
|
||||
|
||||
@@ -130,7 +130,7 @@ class _InventoryEditScreenState extends ConsumerState<InventoryEditScreen> {
|
||||
body: itemAsync.when(
|
||||
loading: () => const LoadingStateView(label: 'Laddar...'),
|
||||
error: (e, _) => ErrorStateView(
|
||||
message: e.toString(),
|
||||
message: mapErrorToUserMessage(e),
|
||||
onRetry: () => ref.invalidate(inventoryDetailProvider(widget.itemId)),
|
||||
),
|
||||
data: (item) {
|
||||
|
||||
@@ -11,21 +11,87 @@ import '../domain/inventory_item.dart';
|
||||
class InventoryScreen extends ConsumerWidget {
|
||||
const InventoryScreen({super.key});
|
||||
|
||||
static const _locationOptions = <String>['', 'Kyl', 'Frys', 'Skafferi'];
|
||||
static const _sortOptions = <({String value, String label})>[
|
||||
(value: '', label: 'Senast tillagda'),
|
||||
(value: 'nameAsc', label: 'Namn A-O'),
|
||||
(value: 'bestBeforeAsc', label: 'Bast fore stigande'),
|
||||
(value: 'bestBeforeDesc', label: 'Bast fore fallande'),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final location = ref.watch(inventoryLocationFilterProvider);
|
||||
final sort = ref.watch(inventorySortFilterProvider);
|
||||
final inventoryAsync = ref.watch(inventoryProvider);
|
||||
|
||||
return inventoryAsync.when(
|
||||
loading: () => const LoadingStateView(label: 'Laddar inventarie...'),
|
||||
error: (e, _) => ErrorStateView(
|
||||
message: e.toString(),
|
||||
message: mapErrorToUserMessage(e),
|
||||
onRetry: () => ref.invalidate(inventoryProvider),
|
||||
),
|
||||
data: (items) {
|
||||
final filterSection = Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 12, 12, 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Filter och sortering',
|
||||
style: TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: _locationOptions
|
||||
.map(
|
||||
(option) => ChoiceChip(
|
||||
label: Text(option.isEmpty ? 'Alla' : option),
|
||||
selected: location == option,
|
||||
onSelected: (_) => ref
|
||||
.read(inventoryLocationFilterProvider.notifier)
|
||||
.state = option,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
DropdownButtonFormField<String>(
|
||||
value: sort,
|
||||
isExpanded: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Sortering',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
items: _sortOptions
|
||||
.map(
|
||||
(option) => DropdownMenuItem<String>(
|
||||
value: option.value,
|
||||
child: Text(option.label),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
onChanged: (value) {
|
||||
ref.read(inventorySortFilterProvider.notifier).state =
|
||||
value ?? '';
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (items.isEmpty) {
|
||||
return Stack(
|
||||
children: [
|
||||
const EmptyStateView(title: 'Inventariet är tomt.'),
|
||||
ListView(
|
||||
padding: const EdgeInsets.only(bottom: 88),
|
||||
children: [
|
||||
filterSection,
|
||||
const EmptyStateView(title: 'Inventariet ar tomt.'),
|
||||
],
|
||||
),
|
||||
Positioned(
|
||||
right: 16,
|
||||
bottom: 16,
|
||||
@@ -42,10 +108,11 @@ class InventoryScreen extends ConsumerWidget {
|
||||
children: [
|
||||
ListView.separated(
|
||||
padding: const EdgeInsets.only(bottom: 88),
|
||||
itemCount: items.length,
|
||||
itemCount: items.length + 1,
|
||||
separatorBuilder: (_, __) => const Divider(height: 1),
|
||||
itemBuilder: (context, index) {
|
||||
final item = items[index];
|
||||
if (index == 0) return filterSection;
|
||||
final item = items[index - 1];
|
||||
return _InventoryTile(item: item);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -4,6 +4,7 @@ class PantryItem {
|
||||
final String productName;
|
||||
final String? canonicalName;
|
||||
final String? category;
|
||||
final int? categoryId;
|
||||
|
||||
const PantryItem({
|
||||
required this.id,
|
||||
@@ -11,6 +12,7 @@ class PantryItem {
|
||||
required this.productName,
|
||||
this.canonicalName,
|
||||
this.category,
|
||||
this.categoryId,
|
||||
});
|
||||
|
||||
String get displayName {
|
||||
@@ -28,6 +30,7 @@ class PantryItem {
|
||||
productName: (product['name'] ?? '').toString(),
|
||||
canonicalName: product['canonicalName']?.toString(),
|
||||
category: product['category']?.toString(),
|
||||
categoryId: (product['categoryId'] as num?)?.toInt(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,16 @@ class PantryProduct {
|
||||
final String name;
|
||||
final String? canonicalName;
|
||||
final String? category;
|
||||
final int? categoryId;
|
||||
final String? categoryPath;
|
||||
|
||||
const PantryProduct({
|
||||
required this.id,
|
||||
required this.name,
|
||||
this.canonicalName,
|
||||
this.category,
|
||||
this.categoryId,
|
||||
this.categoryPath,
|
||||
});
|
||||
|
||||
String get displayName {
|
||||
@@ -19,11 +23,33 @@ class PantryProduct {
|
||||
}
|
||||
|
||||
factory PantryProduct.fromJson(Map<String, dynamic> json) {
|
||||
final categoryRef = json['categoryRef'];
|
||||
final path = _buildCategoryPath(categoryRef);
|
||||
|
||||
return PantryProduct(
|
||||
id: (json['id'] as num).toInt(),
|
||||
name: (json['name'] ?? '').toString(),
|
||||
canonicalName: json['canonicalName']?.toString(),
|
||||
category: json['category']?.toString(),
|
||||
categoryId: (json['categoryId'] as num?)?.toInt(),
|
||||
categoryPath: path,
|
||||
);
|
||||
}
|
||||
|
||||
static String? _buildCategoryPath(dynamic rawCategoryRef) {
|
||||
if (rawCategoryRef is! Map<String, dynamic>) return null;
|
||||
|
||||
final names = <String>[];
|
||||
dynamic current = rawCategoryRef;
|
||||
while (current is Map<String, dynamic>) {
|
||||
final name = current['name']?.toString().trim();
|
||||
if (name != null && name.isNotEmpty) {
|
||||
names.insert(0, name);
|
||||
}
|
||||
current = current['parent'];
|
||||
}
|
||||
|
||||
if (names.isEmpty) return null;
|
||||
return names.join(' > ');
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import '../../auth/data/auth_providers.dart';
|
||||
import '../../../core/ui/async_state_views.dart';
|
||||
import '../data/pantry_providers.dart';
|
||||
import '../domain/pantry_item.dart';
|
||||
import '../domain/pantry_product.dart';
|
||||
|
||||
class PantryScreen extends ConsumerStatefulWidget {
|
||||
const PantryScreen({super.key});
|
||||
@@ -219,6 +220,19 @@ class _PantryScreenState extends ConsumerState<PantryScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
String _resolveCategory(PantryItem item, Map<int, PantryProduct> productById) {
|
||||
final fromTree = productById[item.productId]?.categoryPath;
|
||||
if (fromTree != null && fromTree.trim().isNotEmpty) {
|
||||
return fromTree;
|
||||
}
|
||||
|
||||
if (item.category != null && item.category!.trim().isNotEmpty) {
|
||||
return item.category!;
|
||||
}
|
||||
|
||||
return 'Ovrigt';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pantryAsync = ref.watch(pantryProvider);
|
||||
@@ -241,6 +255,7 @@ class _PantryScreenState extends ConsumerState<PantryScreen> {
|
||||
|
||||
final pantryItems = pantryAsync.valueOrNull ?? const [];
|
||||
final products = productsAsync.valueOrNull ?? const [];
|
||||
final productById = {for (final product in products) product.id: product};
|
||||
final pantryProductIds = pantryItems.map((e) => e.productId).toSet();
|
||||
final availableProducts = products
|
||||
.where((product) => !pantryProductIds.contains(product.id))
|
||||
@@ -252,9 +267,7 @@ class _PantryScreenState extends ConsumerState<PantryScreen> {
|
||||
|
||||
final grouped = <String, List<PantryItem>>{};
|
||||
for (final item in pantryItems) {
|
||||
final category = (item.category == null || item.category!.isEmpty)
|
||||
? 'Ovrigt'
|
||||
: item.category!;
|
||||
final category = _resolveCategory(item, productById);
|
||||
grouped.putIfAbsent(category, () => []).add(item);
|
||||
}
|
||||
final categories = grouped.keys.toList()
|
||||
|
||||
Reference in New Issue
Block a user