feat: add Flutter quality checks and tests for category chips in inventory and pantry screens
Test Suite / test (24.15.0) (push) Has been cancelled
Test Suite / flutter-quality (push) Has been cancelled

This commit is contained in:
Nils-Johan Gynther
2026-05-12 16:13:10 +02:00
parent 08d14bf9e6
commit d645d3ad9d
8 changed files with 328 additions and 14 deletions
@@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart';
import '../../../core/api/api_error_mapper.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 '../domain/inventory_item.dart';
import '../data/inventory_providers.dart';
@@ -19,6 +20,7 @@ class InventoryScreen extends ConsumerStatefulWidget {
class _InventoryScreenState extends ConsumerState<InventoryScreen> {
final Set<int> _selectedIds = <int>{};
static const _sortByDisplayedCategory = 'l1CategoryAsc';
static const _locationOptions = <String>['', 'Kyl', 'Frys', 'Skafferi'];
@@ -32,7 +34,7 @@ class _InventoryScreenState extends ConsumerState<InventoryScreen> {
(value: 'nameAsc', label: context.l10n.inventorySortNameAsc),
(value: 'bestBeforeAsc', label: context.l10n.inventorySortBestBeforeAsc),
(value: 'bestBeforeDesc', label: context.l10n.inventorySortBestBeforeDesc),
(value: 'l1CategoryAsc', label: 'L1-kategori (A-O)'),
(value: _sortByDisplayedCategory, label: 'Kategori (A-O)'),
];
void _startSelection(int id) {
@@ -263,11 +265,18 @@ class _InventoryScreenState extends ConsumerState<InventoryScreen> {
List<InventoryItem> _sortedVisibleItems(List<InventoryItem> items, String sort) {
final visibleItems = [...items];
if (sort == 'l1CategoryAsc') {
if (sort == _sortByDisplayedCategory) {
final displayedCategoryById = {
for (final item in visibleItems)
item.id: categoryChipText(
categoryPath: item.categoryPath,
fallbackL1: item.l1Category,
).label.toLowerCase(),
};
visibleItems.sort((a, b) {
final byCategory = a.l1Category.toLowerCase().compareTo(
b.l1Category.toLowerCase(),
);
final aCategory = displayedCategoryById[a.id] ?? '';
final bCategory = displayedCategoryById[b.id] ?? '';
final byCategory = aCategory.compareTo(bCategory);
if (byCategory != 0) return byCategory;
return a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase());
});
@@ -24,13 +24,14 @@ class PantryScreen extends ConsumerStatefulWidget {
class _PantryScreenState extends ConsumerState<PantryScreen> {
static const _locationOptions = <String>['', 'Kyl', 'Frys', 'Skafferi'];
static const _sortByDisplayedCategory = 'l1CategoryAsc';
String _locationFilter = '';
String _sort = 'nameAsc';
List<({String value, String label})> _sortOptions() => const [
(value: 'nameAsc', label: 'Namn (A-O)'),
(value: 'nameDesc', label: 'Namn (O-A)'),
(value: 'l1CategoryAsc', label: 'L1-kategori (A-O)'),
(value: _sortByDisplayedCategory, label: 'Kategori (A-O)'),
(value: 'locationAsc', label: 'Plats (A-O)'),
];
@@ -172,7 +173,7 @@ class _PantryScreenState extends ConsumerState<PantryScreen> {
SnackBar(content: Text('Flyttade "${item.displayName}" till inventarie.')),
);
} catch (error) {
_logger.severe('Failed to add item to inventory: $error');
_logger.severe('Failed to add item to inventory');
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
buildCopyableErrorSnackBar(context, mapErrorToUserMessage(error, context)),
@@ -206,7 +207,7 @@ class _PantryScreenState extends ConsumerState<PantryScreen> {
await ref.read(pantryRepositoryProvider).deletePantryItem(item.id, token: token);
ref.invalidate(pantryProvider);
} catch (error) {
_logger.severe('Failed to remove pantry item: $error');
_logger.severe('Failed to remove pantry item');
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
buildCopyableErrorSnackBar(context, mapErrorToUserMessage(error, context)),
@@ -231,7 +232,7 @@ class _PantryScreenState extends ConsumerState<PantryScreen> {
if (pantryAsync.hasError) {
final error = pantryAsync.error;
_logger.severe('Error loading pantry or products: $error');
_logger.severe('Error loading pantry or products');
return buildCopyableErrorPanel(
context: context,
message: mapErrorToUserMessage(error ?? 'Okänt fel', context),
@@ -250,9 +251,12 @@ class _PantryScreenState extends ConsumerState<PantryScreen> {
return (item.location ?? '').trim() == _locationFilter;
}).toList();
final l1LowerByItemId = {
final displayedCategoryLowerByItemId = {
for (final item in filteredItems)
item.id: _resolveL1Category(item).toLowerCase(),
item.id: categoryChipText(
categoryPath: item.categoryPath,
fallbackL1: _resolveL1Category(item),
).label.toLowerCase(),
};
filteredItems.sort((a, b) {
@@ -266,9 +270,11 @@ class _PantryScreenState extends ConsumerState<PantryScreen> {
if (byLocation != 0) return byLocation;
return a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase());
}
if (_sort == 'l1CategoryAsc') {
final byL1 = (l1LowerByItemId[a.id] ?? '').compareTo(l1LowerByItemId[b.id] ?? '');
if (byL1 != 0) return byL1;
if (_sort == _sortByDisplayedCategory) {
final byCategory = (displayedCategoryLowerByItemId[a.id] ?? '').compareTo(
displayedCategoryLowerByItemId[b.id] ?? '',
);
if (byCategory != 0) return byCategory;
return a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase());
}
return a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase());
@@ -0,0 +1,63 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:recipe_flutter/features/inventory/domain/inventory_item.dart';
import 'package:recipe_flutter/features/inventory/presentation/swipeable_inventory_tile.dart';
Widget _wrap(Widget child) {
return ProviderScope(
child: MaterialApp(
home: Scaffold(body: child),
),
);
}
void main() {
testWidgets('shows deepest category in chip and full path in tooltip', (
tester,
) async {
const item = InventoryItem(
id: 1,
productId: 99,
productName: 'Jordgubbssylt',
productCanonicalName: 'Jordgubbssylt',
categoryPath: 'Skafferi > Sylt, mos & marmelad > Sylt',
quantity: 1,
unit: 'st',
opened: false,
);
await tester.pumpWidget(_wrap(const SwipeableInventoryTile(item: item)));
expect(find.text('Sylt'), findsOneWidget);
final tooltipFinder = find.ancestor(
of: find.byType(Chip).first,
matching: find.byType(Tooltip),
);
final tooltip = tester.widget<Tooltip>(tooltipFinder);
expect(tooltip.message, 'Skafferi > Sylt, mos & marmelad > Sylt');
});
testWidgets('falls back to L1 when category path is missing', (tester) async {
const item = InventoryItem(
id: 2,
productId: 100,
productName: 'Okategoriserad vara',
productCanonicalName: 'Okategoriserad vara',
categoryPath: null,
quantity: 2,
unit: 'st',
opened: false,
);
await tester.pumpWidget(_wrap(const SwipeableInventoryTile(item: item)));
expect(find.text('Övrigt'), findsOneWidget);
final tooltipFinder = find.ancestor(
of: find.byType(Chip).first,
matching: find.byType(Tooltip),
);
final tooltip = tester.widget<Tooltip>(tooltipFinder);
expect(tooltip.message, 'Övrigt');
});
}
@@ -0,0 +1,82 @@
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:recipe_flutter/core/l10n/l10n.dart';
import 'package:recipe_flutter/features/pantry/data/pantry_providers.dart';
import 'package:recipe_flutter/features/pantry/domain/pantry_item.dart';
import 'package:recipe_flutter/features/pantry/presentation/pantry_screen.dart';
Widget _buildTestApp(List<PantryItem> items) {
return ProviderScope(
overrides: [
pantryProvider.overrideWith((ref) => items),
],
child: MaterialApp(
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: AppLocalizations.supportedLocales,
locale: const Locale('sv'),
home: const Scaffold(body: PantryScreen()),
),
);
}
void main() {
testWidgets('shows deepest category in pantry chip and full path in tooltip', (
tester,
) async {
const items = [
PantryItem(
id: 1,
productId: 7,
productName: 'Jordgubbssylt',
canonicalName: 'Jordgubbssylt',
categoryPath: 'Skafferi > Sylt, mos & marmelad > Sylt',
location: 'Skafferi',
),
];
await tester.pumpWidget(_buildTestApp(items));
await tester.pumpAndSettle();
expect(find.text('Sylt'), findsOneWidget);
final tooltipFinder = find.ancestor(
of: find.text('Sylt'),
matching: find.byType(Tooltip),
);
final tooltip = tester.widget<Tooltip>(tooltipFinder);
expect(tooltip.message, 'Skafferi > Sylt, mos & marmelad > Sylt');
});
testWidgets('falls back to L1/category when category path is missing', (
tester,
) async {
const items = [
PantryItem(
id: 2,
productId: 8,
productName: 'Vetemjol',
canonicalName: 'Vetemjol',
category: 'Skafferi',
categoryPath: null,
location: 'Skafferi',
),
];
await tester.pumpWidget(_buildTestApp(items));
await tester.pumpAndSettle();
expect(find.text('Skafferi'), findsWidgets);
final itemTooltipFinder = find.ancestor(
of: find.byType(Chip).last,
matching: find.byType(Tooltip),
);
final tooltip = tester.widget<Tooltip>(itemTooltipFinder);
expect(tooltip.message, 'Skafferi');
});
}