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:
parent
42c16c0e3f
commit
9558115c1e
9 changed files with 337 additions and 24 deletions
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue