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 '../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()
|
||||||
|
|||||||
Reference in New Issue
Block a user