tane/apps/app_seeds/lib/state/inventory_cubit.dart
vjrj 9558115c1e 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.
2026-07-09 12:00:34 +02:00

123 lines
3.6 KiB
Dart

import 'dart:async';
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
/// 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 {
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,
categoryFilter,
typeFilter,
loading,
];
}
/// Subscribes to the repository's reactive inventory stream. The list updates
/// automatically after a quick-add — no manual refresh.
class InventoryCubit extends Cubit<InventoryState> {
InventoryCubit(this._repo) : super(const InventoryState()) {
_sub = _repo.watchInventory().listen(
(items) => emit(state.copyWith(items: items, loading: false)),
);
}
final VarietyRepository _repo;
late final StreamSubscription<List<VarietyListItem>> _sub;
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();
return super.close();
}
}