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
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue