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,

View file

@ -60,6 +60,8 @@
"title": "Inventory",
"searchHint": "Search seeds",
"empty": "No seeds yet. Tap + to add your first.",
"noMatches": "No seeds match your filters.",
"clearFilters": "Clear filters",
"uncategorized": "Uncategorized"
},
"quickAdd": {

View file

@ -60,6 +60,8 @@
"title": "Inventario",
"searchHint": "Buscar semillas",
"empty": "Aún no hay semillas. Toca + para añadir la primera.",
"noMatches": "Ninguna semilla coincide con los filtros.",
"clearFilters": "Quitar filtros",
"uncategorized": "Sin categoría"
},
"quickAdd": {

View file

@ -4,9 +4,9 @@
/// To regenerate, run: `dart run slang`
///
/// Locales: 2
/// Strings: 332 (166 per locale)
/// Strings: 336 (168 per locale)
///
/// Built on 2026-07-09 at 09:25 UTC
/// Built on 2026-07-09 at 09:55 UTC
// coverage:ignore-file
// ignore_for_file: type=lint, unused_import

View file

@ -268,6 +268,12 @@ class Translations$inventory$en {
/// en: 'No seeds yet. Tap + to add your first.'
String get empty => 'No seeds yet. Tap + to add your first.';
/// en: 'No seeds match your filters.'
String get noMatches => 'No seeds match your filters.';
/// en: 'Clear filters'
String get clearFilters => 'Clear filters';
/// en: 'Uncategorized'
String get uncategorized => 'Uncategorized';
}
@ -984,6 +990,8 @@ extension on Translations {
'inventory.title' => 'Inventory',
'inventory.searchHint' => 'Search seeds',
'inventory.empty' => 'No seeds yet. Tap + to add your first.',
'inventory.noMatches' => 'No seeds match your filters.',
'inventory.clearFilters' => 'Clear filters',
'inventory.uncategorized' => 'Uncategorized',
'quickAdd.title' => 'Add a seed',
'quickAdd.labelField' => 'Name',

View file

@ -174,6 +174,8 @@ class _Translations$inventory$es extends Translations$inventory$en {
@override String get title => 'Inventario';
@override String get searchHint => 'Buscar semillas';
@override String get empty => 'Aún no hay semillas. Toca + para añadir la primera.';
@override String get noMatches => 'Ninguna semilla coincide con los filtros.';
@override String get clearFilters => 'Quitar filtros';
@override String get uncategorized => 'Sin categoría';
}
@ -673,6 +675,8 @@ extension on TranslationsEs {
'inventory.title' => 'Inventario',
'inventory.searchHint' => 'Buscar semillas',
'inventory.empty' => 'Aún no hay semillas. Toca + para añadir la primera.',
'inventory.noMatches' => 'Ninguna semilla coincide con los filtros.',
'inventory.clearFilters' => 'Quitar filtros',
'inventory.uncategorized' => 'Sin categoría',
'quickAdd.title' => 'Añadir una semilla',
'quickAdd.labelField' => 'Nombre',

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();

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),
),
);
}
}