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

@ -3,9 +3,11 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import '../data/variety_repository.dart';
import '../db/enums.dart';
import '../i18n/strings.g.dart';
import '../state/inventory_cubit.dart';
import 'app_drawer.dart';
import 'quantity_picker.dart';
import 'quick_add_sheet.dart';
import 'seed_glyph.dart';
import 'theme.dart';
@ -57,7 +59,16 @@ class InventoryListScreen extends StatelessWidget {
onChanged: context.read<InventoryCubit>().search,
),
),
Expanded(child: _InventoryBody(items: state.visibleItems)),
_FilterBar(state: state),
Expanded(
child: _InventoryBody(
items: state.visibleItems,
// Distinguish "no seeds at all" from "filters hid them all".
filtered:
state.categoryFilter.isNotEmpty ||
state.typeFilter.isNotEmpty,
),
),
],
);
},
@ -66,11 +77,76 @@ class InventoryListScreen extends StatelessWidget {
}
}
/// A horizontally scrolling row of filter chips: one per category in use plus
/// one per lot form actually held. Only shown once there is something to filter.
class _FilterBar extends StatelessWidget {
const _FilterBar({required this.state});
final InventoryState state;
@override
Widget build(BuildContext context) {
final t = context.t;
final cubit = context.read<InventoryCubit>();
final categories = state.categories;
// Only offer form chips for forms that some variety actually holds.
final forms = <LotType>[
for (final type in LotType.values)
if (state.items.any((i) => i.lotTypes.contains(type))) type,
];
if (categories.isEmpty && forms.isEmpty) return const SizedBox.shrink();
final chips = <Widget>[
for (final category in categories)
FilterChip(
key: Key('inventory.filter.category.$category'),
label: Text(category),
selected: state.categoryFilter.contains(category),
onSelected: (_) => cubit.toggleCategory(category),
),
for (final form in forms)
FilterChip(
key: Key('inventory.filter.type.${form.name}'),
label: Text(lotTypeLabel(t, form)),
selected: state.typeFilter.contains(form),
onSelected: (_) => cubit.toggleType(form),
),
];
final hasActiveFilter =
state.categoryFilter.isNotEmpty || state.typeFilter.isNotEmpty;
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
for (final chip in chips)
Padding(
padding: const EdgeInsets.only(right: 8),
child: chip,
),
if (hasActiveFilter)
TextButton.icon(
key: const Key('inventory.filter.clear'),
onPressed: cubit.clearFilters,
icon: const Icon(Icons.clear, size: 18),
label: Text(t.inventory.clearFilters),
),
],
),
);
}
}
class _InventoryBody extends StatelessWidget {
const _InventoryBody({required this.items});
const _InventoryBody({required this.items, this.filtered = false});
final List<VarietyListItem> items;
/// Whether a filter is active, so an empty list reads "no matches" rather
/// than "no seeds yet".
final bool filtered;
@override
Widget build(BuildContext context) {
final t = context.t;
@ -88,7 +164,7 @@ class _InventoryBody extends StatelessWidget {
),
const SizedBox(height: 16),
Text(
t.inventory.empty,
filtered ? t.inventory.noMatches : t.inventory.empty,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge,
),
@ -123,10 +199,10 @@ class _CategoryHeader extends StatelessWidget {
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 18, 16, 6),
// Base on the text theme so it honours the system font-scale factor.
child: Text(
title,
style: const TextStyle(
fontSize: 16,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w500,
color: seedGreen,
),
@ -157,7 +233,9 @@ class _VarietyTile extends StatelessWidget {
),
trailing: IconButton(
icon: const Icon(Icons.edit_outlined),
color: seedMuted,
// Action colour (3:1 on the canvas) so the control stays legible;
// seedMuted here fell to 2.62:1.
color: seedGreen,
tooltip: context.t.common.edit,
onPressed: open,
),
@ -175,20 +253,26 @@ class _Avatar extends StatelessWidget {
@override
Widget build(BuildContext context) {
final photo = item.photo;
// Decorative: the tile title already announces the variety name, so keep
// the thumbnail / initial out of the semantics tree.
if (photo != null) {
return ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.memory(photo, width: 48, height: 48, fit: BoxFit.cover),
return ExcludeSemantics(
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.memory(photo, width: 48, height: 48, fit: BoxFit.cover),
),
);
}
final trimmed = item.label.trim();
final initial = trimmed.isEmpty
? '?'
: trimmed.substring(0, 1).toUpperCase();
return CircleAvatar(
backgroundColor: seedAvatar,
foregroundColor: seedOnAvatar,
child: Text(initial),
return ExcludeSemantics(
child: CircleAvatar(
backgroundColor: seedAvatar,
foregroundColor: seedOnAvatar,
child: Text(initial),
),
);
}
}