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

@ -14,6 +14,7 @@ class VarietyListItem extends Equatable {
this.category,
this.scientificName,
this.photo,
this.lotTypes = const {},
});
final String id;
@ -26,8 +27,19 @@ class VarietyListItem extends Equatable {
/// First photo (encrypted BLOB) for the avatar, or null show an initial.
final Uint8List? photo;
/// Distinct lot forms this variety currently holds (seed, plant, tree),
/// used to filter the list by form. Empty when it has no lots yet.
final Set<LotType> lotTypes;
@override
List<Object?> get props => [id, label, category, scientificName, photo];
List<Object?> get props => [
id,
label,
category,
scientificName,
photo,
lotTypes,
];
}
/// One germination test on a lot; [rate] is derived (0..1), null when it can't
@ -221,6 +233,8 @@ class VarietyRepository {
)..where((v) => v.isDeleted.equals(false))).watch().map((_) {}),
_db.select(_db.attachments).watch().map((_) {}),
_db.select(_db.species).watch().map((_) {}),
// Lots drive the form filter, so re-emit when they change too.
_db.select(_db.lots).watch().map((_) {}),
]);
return triggers.asyncMap((_) => _loadInventory());
}
@ -234,10 +248,12 @@ class VarietyRepository {
(v) => OrderingTerm(expression: v.label),
]))
.get();
final photos = await _firstPhotosFor(rows.map((v) => v.id).toList());
final varietyIds = rows.map((v) => v.id).toList();
final photos = await _firstPhotosFor(varietyIds);
final sciNames = await _scientificNamesFor(
rows.map((v) => v.speciesId).whereType<String>().toSet(),
);
final lotTypes = await _lotTypesFor(varietyIds);
return rows
.map(
(v) => VarietyListItem(
@ -246,11 +262,30 @@ class VarietyRepository {
category: v.category,
scientificName: v.speciesId == null ? null : sciNames[v.speciesId],
photo: photos[v.id],
lotTypes: lotTypes[v.id] ?? const {},
),
)
.toList();
}
/// Maps each variety to the distinct lot forms it currently holds (one
/// query). Varieties without lots are simply absent from the map.
Future<Map<String, Set<LotType>>> _lotTypesFor(
List<String> varietyIds,
) async {
if (varietyIds.isEmpty) return const {};
final rows =
await (_db.select(_db.lots)..where(
(l) => l.varietyId.isIn(varietyIds) & l.isDeleted.equals(false),
))
.get();
final byVariety = <String, Set<LotType>>{};
for (final row in rows) {
(byVariety[row.varietyId] ??= <LotType>{}).add(row.type);
}
return byVariety;
}
/// Loads the first photo BLOB for each of [varietyIds] (one query).
Future<Map<String, Uint8List>> _firstPhotosFor(
List<String> varietyIds,