feat: enhance inventory and pantry features with filtering, sorting, and error handling improvements

This commit is contained in:
Nils-Johan Gynther
2026-04-22 18:14:19 +02:00
parent dd05fed279
commit 07ed164112
8 changed files with 142 additions and 11 deletions
@@ -7,15 +7,36 @@ import '../domain/inventory_item.dart';
import '../domain/inventory_consumption.dart'; import '../domain/inventory_consumption.dart';
import 'inventory_repository.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) { final inventoryRepositoryProvider = Provider<InventoryRepository>((ref) {
return InventoryRepository(ref.watch(apiClientProvider)); return InventoryRepository(ref.watch(apiClientProvider));
}); });
final inventoryProvider = FutureProvider<List<InventoryItem>>((ref) async { final inventoryProvider = FutureProvider<List<InventoryItem>>((ref) async {
final token = await ref.watch(authStateProvider.future); final token = await ref.watch(authStateProvider.future);
final query = ref.watch(inventoryQueryProvider);
return guardedApiCall( return guardedApiCall(
ref, 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/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/api/api_error_mapper.dart';
import '../../../core/ui/async_state_views.dart'; import '../../../core/ui/async_state_views.dart';
import '../data/inventory_providers.dart'; import '../data/inventory_providers.dart';
import '../domain/inventory_consumption.dart'; import '../domain/inventory_consumption.dart';
@@ -25,7 +26,7 @@ class ConsumptionHistoryScreen extends ConsumerWidget {
body: historyAsync.when( body: historyAsync.when(
loading: () => const LoadingStateView(label: 'Laddar historik...'), loading: () => const LoadingStateView(label: 'Laddar historik...'),
error: (e, _) => ErrorStateView( error: (e, _) => ErrorStateView(
message: e.toString(), message: mapErrorToUserMessage(e),
onRetry: () => ref.invalidate(consumptionHistoryProvider(itemId)), onRetry: () => ref.invalidate(consumptionHistoryProvider(itemId)),
), ),
data: (history) { data: (history) {
@@ -36,7 +36,7 @@ class InventoryDetailScreen extends ConsumerWidget {
body: itemAsync.when( body: itemAsync.when(
loading: () => const LoadingStateView(label: 'Laddar...'), loading: () => const LoadingStateView(label: 'Laddar...'),
error: (e, _) => ErrorStateView( error: (e, _) => ErrorStateView(
message: e.toString(), message: mapErrorToUserMessage(e),
onRetry: () => ref.invalidate(inventoryDetailProvider(itemId)), onRetry: () => ref.invalidate(inventoryDetailProvider(itemId)),
), ),
data: (item) => ListView( data: (item) => ListView(
@@ -130,7 +130,7 @@ class _InventoryEditScreenState extends ConsumerState<InventoryEditScreen> {
body: itemAsync.when( body: itemAsync.when(
loading: () => const LoadingStateView(label: 'Laddar...'), loading: () => const LoadingStateView(label: 'Laddar...'),
error: (e, _) => ErrorStateView( error: (e, _) => ErrorStateView(
message: e.toString(), message: mapErrorToUserMessage(e),
onRetry: () => ref.invalidate(inventoryDetailProvider(widget.itemId)), onRetry: () => ref.invalidate(inventoryDetailProvider(widget.itemId)),
), ),
data: (item) { data: (item) {
@@ -11,21 +11,87 @@ import '../domain/inventory_item.dart';
class InventoryScreen extends ConsumerWidget { class InventoryScreen extends ConsumerWidget {
const InventoryScreen({super.key}); 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 @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final location = ref.watch(inventoryLocationFilterProvider);
final sort = ref.watch(inventorySortFilterProvider);
final inventoryAsync = ref.watch(inventoryProvider); final inventoryAsync = ref.watch(inventoryProvider);
return inventoryAsync.when( return inventoryAsync.when(
loading: () => const LoadingStateView(label: 'Laddar inventarie...'), loading: () => const LoadingStateView(label: 'Laddar inventarie...'),
error: (e, _) => ErrorStateView( error: (e, _) => ErrorStateView(
message: e.toString(), message: mapErrorToUserMessage(e),
onRetry: () => ref.invalidate(inventoryProvider), onRetry: () => ref.invalidate(inventoryProvider),
), ),
data: (items) { 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) { if (items.isEmpty) {
return Stack( return Stack(
children: [ children: [
const EmptyStateView(title: 'Inventariet är tomt.'), ListView(
padding: const EdgeInsets.only(bottom: 88),
children: [
filterSection,
const EmptyStateView(title: 'Inventariet ar tomt.'),
],
),
Positioned( Positioned(
right: 16, right: 16,
bottom: 16, bottom: 16,
@@ -42,10 +108,11 @@ class InventoryScreen extends ConsumerWidget {
children: [ children: [
ListView.separated( ListView.separated(
padding: const EdgeInsets.only(bottom: 88), padding: const EdgeInsets.only(bottom: 88),
itemCount: items.length, itemCount: items.length + 1,
separatorBuilder: (_, __) => const Divider(height: 1), separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, index) { itemBuilder: (context, index) {
final item = items[index]; if (index == 0) return filterSection;
final item = items[index - 1];
return _InventoryTile(item: item); return _InventoryTile(item: item);
}, },
), ),
@@ -4,6 +4,7 @@ class PantryItem {
final String productName; final String productName;
final String? canonicalName; final String? canonicalName;
final String? category; final String? category;
final int? categoryId;
const PantryItem({ const PantryItem({
required this.id, required this.id,
@@ -11,6 +12,7 @@ class PantryItem {
required this.productName, required this.productName,
this.canonicalName, this.canonicalName,
this.category, this.category,
this.categoryId,
}); });
String get displayName { String get displayName {
@@ -28,6 +30,7 @@ class PantryItem {
productName: (product['name'] ?? '').toString(), productName: (product['name'] ?? '').toString(),
canonicalName: product['canonicalName']?.toString(), canonicalName: product['canonicalName']?.toString(),
category: product['category']?.toString(), category: product['category']?.toString(),
categoryId: (product['categoryId'] as num?)?.toInt(),
); );
} }
} }
@@ -3,12 +3,16 @@ class PantryProduct {
final String name; final String name;
final String? canonicalName; final String? canonicalName;
final String? category; final String? category;
final int? categoryId;
final String? categoryPath;
const PantryProduct({ const PantryProduct({
required this.id, required this.id,
required this.name, required this.name,
this.canonicalName, this.canonicalName,
this.category, this.category,
this.categoryId,
this.categoryPath,
}); });
String get displayName { String get displayName {
@@ -19,11 +23,33 @@ class PantryProduct {
} }
factory PantryProduct.fromJson(Map<String, dynamic> json) { factory PantryProduct.fromJson(Map<String, dynamic> json) {
final categoryRef = json['categoryRef'];
final path = _buildCategoryPath(categoryRef);
return PantryProduct( return PantryProduct(
id: (json['id'] as num).toInt(), id: (json['id'] as num).toInt(),
name: (json['name'] ?? '').toString(), name: (json['name'] ?? '').toString(),
canonicalName: json['canonicalName']?.toString(), canonicalName: json['canonicalName']?.toString(),
category: json['category']?.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 '../../../core/ui/async_state_views.dart';
import '../data/pantry_providers.dart'; import '../data/pantry_providers.dart';
import '../domain/pantry_item.dart'; import '../domain/pantry_item.dart';
import '../domain/pantry_product.dart';
class PantryScreen extends ConsumerStatefulWidget { class PantryScreen extends ConsumerStatefulWidget {
const PantryScreen({super.key}); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final pantryAsync = ref.watch(pantryProvider); final pantryAsync = ref.watch(pantryProvider);
@@ -241,6 +255,7 @@ class _PantryScreenState extends ConsumerState<PantryScreen> {
final pantryItems = pantryAsync.valueOrNull ?? const []; final pantryItems = pantryAsync.valueOrNull ?? const [];
final products = productsAsync.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 pantryProductIds = pantryItems.map((e) => e.productId).toSet();
final availableProducts = products final availableProducts = products
.where((product) => !pantryProductIds.contains(product.id)) .where((product) => !pantryProductIds.contains(product.id))
@@ -252,9 +267,7 @@ class _PantryScreenState extends ConsumerState<PantryScreen> {
final grouped = <String, List<PantryItem>>{}; final grouped = <String, List<PantryItem>>{};
for (final item in pantryItems) { for (final item in pantryItems) {
final category = (item.category == null || item.category!.isEmpty) final category = _resolveCategory(item, productById);
? 'Ovrigt'
: item.category!;
grouped.putIfAbsent(category, () => []).add(item); grouped.putIfAbsent(category, () => []).add(item);
} }
final categories = grouped.keys.toList() final categories = grouped.keys.toList()