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
|
|
@ -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