diff --git a/apps/app_seeds/lib/data/variety_repository.dart b/apps/app_seeds/lib/data/variety_repository.dart index 281cd21..e693dff 100644 --- a/apps/app_seeds/lib/data/variety_repository.dart +++ b/apps/app_seeds/lib/data/variety_repository.dart @@ -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 lotTypes; + @override - List get props => [id, label, category, scientificName, photo]; + List 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().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>> _lotTypesFor( + List 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 = >{}; + for (final row in rows) { + (byVariety[row.varietyId] ??= {}).add(row.type); + } + return byVariety; + } + /// Loads the first photo BLOB for each of [varietyIds] (one query). Future> _firstPhotosFor( List varietyIds, diff --git a/apps/app_seeds/lib/i18n/en.i18n.json b/apps/app_seeds/lib/i18n/en.i18n.json index 4ee1fa9..99579b9 100644 --- a/apps/app_seeds/lib/i18n/en.i18n.json +++ b/apps/app_seeds/lib/i18n/en.i18n.json @@ -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": { diff --git a/apps/app_seeds/lib/i18n/es.i18n.json b/apps/app_seeds/lib/i18n/es.i18n.json index 316a4e2..9b47139 100644 --- a/apps/app_seeds/lib/i18n/es.i18n.json +++ b/apps/app_seeds/lib/i18n/es.i18n.json @@ -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": { diff --git a/apps/app_seeds/lib/i18n/strings.g.dart b/apps/app_seeds/lib/i18n/strings.g.dart index 1e189bb..0f11cdb 100644 --- a/apps/app_seeds/lib/i18n/strings.g.dart +++ b/apps/app_seeds/lib/i18n/strings.g.dart @@ -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 diff --git a/apps/app_seeds/lib/i18n/strings_en.g.dart b/apps/app_seeds/lib/i18n/strings_en.g.dart index 344c748..818a1d1 100644 --- a/apps/app_seeds/lib/i18n/strings_en.g.dart +++ b/apps/app_seeds/lib/i18n/strings_en.g.dart @@ -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', diff --git a/apps/app_seeds/lib/i18n/strings_es.g.dart b/apps/app_seeds/lib/i18n/strings_es.g.dart index 75e3581..9979397 100644 --- a/apps/app_seeds/lib/i18n/strings_es.g.dart +++ b/apps/app_seeds/lib/i18n/strings_es.g.dart @@ -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', diff --git a/apps/app_seeds/lib/state/inventory_cubit.dart b/apps/app_seeds/lib/state/inventory_cubit.dart index 725307f..a455116 100644 --- a/apps/app_seeds/lib/state/inventory_cubit.dart +++ b/apps/app_seeds/lib/state/inventory_cubit.dart @@ -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 items; final String query; + + /// Categories to keep; empty = all categories. + final Set categoryFilter; + + /// Lot forms to keep; empty = all forms. An item matches if it holds at + /// least one lot of a selected form. + final Set 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 get categories { + final seen = {}; + final result = []; + for (final item in items) { + final category = item.category; + if (category != null && seen.add(category)) result.add(category); + } + return result; + } + List 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? items, String? query, + Set? categoryFilter, + Set? 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 get props => [items, query, loading]; + List 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 { 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.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.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 close() async { await _sub.cancel(); diff --git a/apps/app_seeds/lib/ui/inventory_list_screen.dart b/apps/app_seeds/lib/ui/inventory_list_screen.dart index 1cdf090..f782e23 100644 --- a/apps/app_seeds/lib/ui/inventory_list_screen.dart +++ b/apps/app_seeds/lib/ui/inventory_list_screen.dart @@ -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().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(); + final categories = state.categories; + // Only offer form chips for forms that some variety actually holds. + final forms = [ + 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 = [ + 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 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), + ), ); } } diff --git a/apps/app_seeds/test/ui/inventory_list_screen_test.dart b/apps/app_seeds/test/ui/inventory_list_screen_test.dart index 432a02b..4473f13 100644 --- a/apps/app_seeds/test/ui/inventory_list_screen_test.dart +++ b/apps/app_seeds/test/ui/inventory_list_screen_test.dart @@ -1,8 +1,10 @@ import 'dart:typed_data'; +import 'package:commons_core/commons_core.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:tane/db/database.dart'; +import 'package:tane/db/enums.dart'; import 'package:tane/i18n/strings.g.dart'; import 'package:tane/ui/inventory_list_screen.dart'; @@ -48,7 +50,8 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Grandma tomato'), findsOneWidget); - expect(find.text('Solanaceae'), findsOneWidget); // category header + // The category now appears both as a group header and a filter chip. + expect(find.text('Solanaceae'), findsWidgets); await disposeTree(tester); }, ); @@ -71,6 +74,120 @@ void main() { await disposeTree(tester); }); + testWidgets('a category chip filters the list to that category', ( + tester, + ) async { + final repo = newTestRepository(db); + await repo.addQuickVariety(label: 'Tomato', category: 'Solanaceae'); + await repo.addQuickVariety(label: 'Bean', category: 'Fabaceae'); + + await tester.pumpWidget( + wrapScreen(repository: repo, child: const InventoryListScreen()), + ); + await tester.pumpAndSettle(); + + await tester.tap( + find.byKey(const Key('inventory.filter.category.Solanaceae')), + ); + await tester.pumpAndSettle(); + + expect(find.text('Tomato'), findsOneWidget); + expect(find.text('Bean'), findsNothing); + await disposeTree(tester); + }); + + testWidgets('a form chip filters the list to that lot form', (tester) async { + final repo = newTestRepository(db); + const oneUnit = Quantity(kind: QuantityKind.packet, count: 1); + await repo.addQuickVariety( + label: 'SeedOnly', + quantity: oneUnit, + lotType: LotType.seed, + ); + await repo.addQuickVariety( + label: 'PlantOnly', + quantity: oneUnit, + lotType: LotType.plant, + ); + + await tester.pumpWidget( + wrapScreen(repository: repo, child: const InventoryListScreen()), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('inventory.filter.type.seed'))); + await tester.pumpAndSettle(); + + expect(find.text('SeedOnly'), findsOneWidget); + expect(find.text('PlantOnly'), findsNothing); + await disposeTree(tester); + }); + + testWidgets('clearing filters restores the full list', (tester) async { + final repo = newTestRepository(db); + await repo.addQuickVariety(label: 'Tomato', category: 'Solanaceae'); + await repo.addQuickVariety(label: 'Bean', category: 'Fabaceae'); + + await tester.pumpWidget( + wrapScreen(repository: repo, child: const InventoryListScreen()), + ); + await tester.pumpAndSettle(); + + await tester.tap( + find.byKey(const Key('inventory.filter.category.Fabaceae')), + ); + await tester.pumpAndSettle(); + expect(find.text('Tomato'), findsNothing); + + await tester.tap(find.byKey(const Key('inventory.filter.clear'))); + await tester.pumpAndSettle(); + expect(find.text('Tomato'), findsOneWidget); + expect(find.text('Bean'), findsOneWidget); + await disposeTree(tester); + }); + + testWidgets('a filter that hides everything shows the no-matches state', ( + tester, + ) async { + final repo = newTestRepository(db); + const oneUnit = Quantity(kind: QuantityKind.packet, count: 1); + await repo.addQuickVariety( + label: 'SeedOnly', + quantity: oneUnit, + lotType: LotType.seed, + ); + + await tester.pumpWidget( + wrapScreen(repository: repo, child: const InventoryListScreen()), + ); + await tester.pumpAndSettle(); + + // Search excludes the only item, so the list empties under an active query. + await tester.enterText( + find.byKey(const Key('inventory.search')), + 'zzz-nope', + ); + await tester.tap(find.byKey(const Key('inventory.filter.type.seed'))); + await tester.pumpAndSettle(); + + expect(find.text('No seeds match your filters.'), findsOneWidget); + await disposeTree(tester); + }); + + testWidgets('meets the tap-target size guideline', (tester) async { + final repo = newTestRepository(db); + await repo.addQuickVariety(label: 'Tomato', category: 'Solanaceae'); + + await tester.pumpWidget( + wrapScreen(repository: repo, child: const InventoryListScreen()), + ); + await tester.pumpAndSettle(); + + await expectLater(tester, meetsGuideline(androidTapTargetGuideline)); + await expectLater(tester, meetsGuideline(iOSTapTargetGuideline)); + await disposeTree(tester); + }); + testWidgets('renders in Spanish when the locale is es', (tester) async { final repo = newTestRepository(db); await tester.pumpWidget(