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

@ -4,40 +4,83 @@ import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../data/variety_repository.dart';
import '../db/enums.dart';
/// Inventory list state: all items from the DB plus the current search query.
/// [visibleItems] applies the query; grouping by category is done in the UI.
/// Inventory list state: all items from the DB plus the current search query
/// and active filters. [visibleItems] applies query category form; grouping
/// by category is done in the UI. Empty filter sets mean "show everything".
class InventoryState extends Equatable {
const InventoryState({
this.items = const [],
this.query = '',
this.categoryFilter = const {},
this.typeFilter = const {},
this.loading = true,
});
final List<VarietyListItem> items;
final String query;
/// Categories to keep; empty = all categories.
final Set<String> categoryFilter;
/// Lot forms to keep; empty = all forms. An item matches if it holds at
/// least one lot of a selected form.
final Set<LotType> typeFilter;
final bool loading;
/// Categories present across all items, in display order (deduped), so the
/// UI can offer one chip per category actually in use.
List<String> get categories {
final seen = <String>{};
final result = <String>[];
for (final item in items) {
final category = item.category;
if (category != null && seen.add(category)) result.add(category);
}
return result;
}
List<VarietyListItem> get visibleItems {
if (query.trim().isEmpty) return items;
final q = query.toLowerCase();
return items.where((i) => i.label.toLowerCase().contains(q)).toList();
final q = query.trim().toLowerCase();
return items.where((i) {
if (q.isNotEmpty && !i.label.toLowerCase().contains(q)) return false;
if (categoryFilter.isNotEmpty &&
!categoryFilter.contains(i.category)) {
return false;
}
if (typeFilter.isNotEmpty && i.lotTypes.intersection(typeFilter).isEmpty) {
return false;
}
return true;
}).toList();
}
InventoryState copyWith({
List<VarietyListItem>? items,
String? query,
Set<String>? categoryFilter,
Set<LotType>? typeFilter,
bool? loading,
}) {
return InventoryState(
items: items ?? this.items,
query: query ?? this.query,
categoryFilter: categoryFilter ?? this.categoryFilter,
typeFilter: typeFilter ?? this.typeFilter,
loading: loading ?? this.loading,
);
}
@override
List<Object?> get props => [items, query, loading];
List<Object?> get props => [
items,
query,
categoryFilter,
typeFilter,
loading,
];
}
/// Subscribes to the repository's reactive inventory stream. The list updates
@ -54,6 +97,24 @@ class InventoryCubit extends Cubit<InventoryState> {
void search(String query) => emit(state.copyWith(query: query));
/// Toggles a category in the filter (add if absent, remove if present).
void toggleCategory(String category) {
final next = Set<String>.of(state.categoryFilter);
if (!next.remove(category)) next.add(category);
emit(state.copyWith(categoryFilter: next));
}
/// Toggles a lot form in the filter (add if absent, remove if present).
void toggleType(LotType type) {
final next = Set<LotType>.of(state.typeFilter);
if (!next.remove(type)) next.add(type);
emit(state.copyWith(typeFilter: next));
}
/// Clears both filters (search is left untouched).
void clearFilters() =>
emit(state.copyWith(categoryFilter: const {}, typeFilter: const {}));
@override
Future<void> close() async {
await _sub.cancel();