feat(inventory): filter by category and lot form + a11y polish

Add filter chips above the inventory list, one per category in use and
one per lot form actually held; results apply search ∧ category ∧ form.
An active filter that empties the list shows a distinct 'no matches'
state and a clear-filters button. VarietyListItem now carries the set of
lot forms it holds (one aggregate query), and the stream re-emits on lot
changes.

Accessibility: category headers use the text theme so they honour the
system font scale; the list avatar is excluded from semantics (the tile
title already announces the name); the edit action uses the green action
colour (≥3:1 on the canvas, up from 2.62:1). Add category/form/clear
filter tests and a tap-target-size guideline test.
This commit is contained in:
vjrj 2026-07-09 12:00:34 +02:00
parent 42c16c0e3f
commit 9558115c1e
9 changed files with 337 additions and 24 deletions

View file

@ -1,8 +1,10 @@
import 'dart:typed_data';
import 'package:commons_core/commons_core.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:tane/db/database.dart';
import 'package:tane/db/enums.dart';
import 'package:tane/i18n/strings.g.dart';
import 'package:tane/ui/inventory_list_screen.dart';
@ -48,7 +50,8 @@ void main() {
await tester.pumpAndSettle();
expect(find.text('Grandma tomato'), findsOneWidget);
expect(find.text('Solanaceae'), findsOneWidget); // category header
// The category now appears both as a group header and a filter chip.
expect(find.text('Solanaceae'), findsWidgets);
await disposeTree(tester);
},
);
@ -71,6 +74,120 @@ void main() {
await disposeTree(tester);
});
testWidgets('a category chip filters the list to that category', (
tester,
) async {
final repo = newTestRepository(db);
await repo.addQuickVariety(label: 'Tomato', category: 'Solanaceae');
await repo.addQuickVariety(label: 'Bean', category: 'Fabaceae');
await tester.pumpWidget(
wrapScreen(repository: repo, child: const InventoryListScreen()),
);
await tester.pumpAndSettle();
await tester.tap(
find.byKey(const Key('inventory.filter.category.Solanaceae')),
);
await tester.pumpAndSettle();
expect(find.text('Tomato'), findsOneWidget);
expect(find.text('Bean'), findsNothing);
await disposeTree(tester);
});
testWidgets('a form chip filters the list to that lot form', (tester) async {
final repo = newTestRepository(db);
const oneUnit = Quantity(kind: QuantityKind.packet, count: 1);
await repo.addQuickVariety(
label: 'SeedOnly',
quantity: oneUnit,
lotType: LotType.seed,
);
await repo.addQuickVariety(
label: 'PlantOnly',
quantity: oneUnit,
lotType: LotType.plant,
);
await tester.pumpWidget(
wrapScreen(repository: repo, child: const InventoryListScreen()),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('inventory.filter.type.seed')));
await tester.pumpAndSettle();
expect(find.text('SeedOnly'), findsOneWidget);
expect(find.text('PlantOnly'), findsNothing);
await disposeTree(tester);
});
testWidgets('clearing filters restores the full list', (tester) async {
final repo = newTestRepository(db);
await repo.addQuickVariety(label: 'Tomato', category: 'Solanaceae');
await repo.addQuickVariety(label: 'Bean', category: 'Fabaceae');
await tester.pumpWidget(
wrapScreen(repository: repo, child: const InventoryListScreen()),
);
await tester.pumpAndSettle();
await tester.tap(
find.byKey(const Key('inventory.filter.category.Fabaceae')),
);
await tester.pumpAndSettle();
expect(find.text('Tomato'), findsNothing);
await tester.tap(find.byKey(const Key('inventory.filter.clear')));
await tester.pumpAndSettle();
expect(find.text('Tomato'), findsOneWidget);
expect(find.text('Bean'), findsOneWidget);
await disposeTree(tester);
});
testWidgets('a filter that hides everything shows the no-matches state', (
tester,
) async {
final repo = newTestRepository(db);
const oneUnit = Quantity(kind: QuantityKind.packet, count: 1);
await repo.addQuickVariety(
label: 'SeedOnly',
quantity: oneUnit,
lotType: LotType.seed,
);
await tester.pumpWidget(
wrapScreen(repository: repo, child: const InventoryListScreen()),
);
await tester.pumpAndSettle();
// Search excludes the only item, so the list empties under an active query.
await tester.enterText(
find.byKey(const Key('inventory.search')),
'zzz-nope',
);
await tester.tap(find.byKey(const Key('inventory.filter.type.seed')));
await tester.pumpAndSettle();
expect(find.text('No seeds match your filters.'), findsOneWidget);
await disposeTree(tester);
});
testWidgets('meets the tap-target size guideline', (tester) async {
final repo = newTestRepository(db);
await repo.addQuickVariety(label: 'Tomato', category: 'Solanaceae');
await tester.pumpWidget(
wrapScreen(repository: repo, child: const InventoryListScreen()),
);
await tester.pumpAndSettle();
await expectLater(tester, meetsGuideline(androidTapTargetGuideline));
await expectLater(tester, meetsGuideline(iOSTapTargetGuideline));
await disposeTree(tester);
});
testWidgets('renders in Spanish when the locale is es', (tester) async {
final repo = newTestRepository(db);
await tester.pumpWidget(