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.category,
|
||||||
this.scientificName,
|
this.scientificName,
|
||||||
this.photo,
|
this.photo,
|
||||||
|
this.lotTypes = const {},
|
||||||
});
|
});
|
||||||
|
|
||||||
final String id;
|
final String id;
|
||||||
|
|
@ -26,8 +27,19 @@ class VarietyListItem extends Equatable {
|
||||||
/// First photo (encrypted BLOB) for the avatar, or null → show an initial.
|
/// First photo (encrypted BLOB) for the avatar, or null → show an initial.
|
||||||
final Uint8List? photo;
|
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
|
@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
|
/// 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((_) {}),
|
)..where((v) => v.isDeleted.equals(false))).watch().map((_) {}),
|
||||||
_db.select(_db.attachments).watch().map((_) {}),
|
_db.select(_db.attachments).watch().map((_) {}),
|
||||||
_db.select(_db.species).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());
|
return triggers.asyncMap((_) => _loadInventory());
|
||||||
}
|
}
|
||||||
|
|
@ -234,10 +248,12 @@ class VarietyRepository {
|
||||||
(v) => OrderingTerm(expression: v.label),
|
(v) => OrderingTerm(expression: v.label),
|
||||||
]))
|
]))
|
||||||
.get();
|
.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(
|
final sciNames = await _scientificNamesFor(
|
||||||
rows.map((v) => v.speciesId).whereType<String>().toSet(),
|
rows.map((v) => v.speciesId).whereType<String>().toSet(),
|
||||||
);
|
);
|
||||||
|
final lotTypes = await _lotTypesFor(varietyIds);
|
||||||
return rows
|
return rows
|
||||||
.map(
|
.map(
|
||||||
(v) => VarietyListItem(
|
(v) => VarietyListItem(
|
||||||
|
|
@ -246,11 +262,30 @@ class VarietyRepository {
|
||||||
category: v.category,
|
category: v.category,
|
||||||
scientificName: v.speciesId == null ? null : sciNames[v.speciesId],
|
scientificName: v.speciesId == null ? null : sciNames[v.speciesId],
|
||||||
photo: photos[v.id],
|
photo: photos[v.id],
|
||||||
|
lotTypes: lotTypes[v.id] ?? const {},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.toList();
|
.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).
|
/// Loads the first photo BLOB for each of [varietyIds] (one query).
|
||||||
Future<Map<String, Uint8List>> _firstPhotosFor(
|
Future<Map<String, Uint8List>> _firstPhotosFor(
|
||||||
List<String> varietyIds,
|
List<String> varietyIds,
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,8 @@
|
||||||
"title": "Inventory",
|
"title": "Inventory",
|
||||||
"searchHint": "Search seeds",
|
"searchHint": "Search seeds",
|
||||||
"empty": "No seeds yet. Tap + to add your first.",
|
"empty": "No seeds yet. Tap + to add your first.",
|
||||||
|
"noMatches": "No seeds match your filters.",
|
||||||
|
"clearFilters": "Clear filters",
|
||||||
"uncategorized": "Uncategorized"
|
"uncategorized": "Uncategorized"
|
||||||
},
|
},
|
||||||
"quickAdd": {
|
"quickAdd": {
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,8 @@
|
||||||
"title": "Inventario",
|
"title": "Inventario",
|
||||||
"searchHint": "Buscar semillas",
|
"searchHint": "Buscar semillas",
|
||||||
"empty": "Aún no hay semillas. Toca + para añadir la primera.",
|
"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"
|
"uncategorized": "Sin categoría"
|
||||||
},
|
},
|
||||||
"quickAdd": {
|
"quickAdd": {
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,9 @@
|
||||||
/// To regenerate, run: `dart run slang`
|
/// To regenerate, run: `dart run slang`
|
||||||
///
|
///
|
||||||
/// Locales: 2
|
/// 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
|
// coverage:ignore-file
|
||||||
// ignore_for_file: type=lint, unused_import
|
// ignore_for_file: type=lint, unused_import
|
||||||
|
|
|
||||||
|
|
@ -268,6 +268,12 @@ class Translations$inventory$en {
|
||||||
/// en: 'No seeds yet. Tap + to add your first.'
|
/// en: 'No seeds yet. Tap + to add your first.'
|
||||||
String get empty => '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'
|
/// en: 'Uncategorized'
|
||||||
String get uncategorized => 'Uncategorized';
|
String get uncategorized => 'Uncategorized';
|
||||||
}
|
}
|
||||||
|
|
@ -984,6 +990,8 @@ extension on Translations {
|
||||||
'inventory.title' => 'Inventory',
|
'inventory.title' => 'Inventory',
|
||||||
'inventory.searchHint' => 'Search seeds',
|
'inventory.searchHint' => 'Search seeds',
|
||||||
'inventory.empty' => 'No seeds yet. Tap + to add your first.',
|
'inventory.empty' => 'No seeds yet. Tap + to add your first.',
|
||||||
|
'inventory.noMatches' => 'No seeds match your filters.',
|
||||||
|
'inventory.clearFilters' => 'Clear filters',
|
||||||
'inventory.uncategorized' => 'Uncategorized',
|
'inventory.uncategorized' => 'Uncategorized',
|
||||||
'quickAdd.title' => 'Add a seed',
|
'quickAdd.title' => 'Add a seed',
|
||||||
'quickAdd.labelField' => 'Name',
|
'quickAdd.labelField' => 'Name',
|
||||||
|
|
|
||||||
|
|
@ -174,6 +174,8 @@ class _Translations$inventory$es extends Translations$inventory$en {
|
||||||
@override String get title => 'Inventario';
|
@override String get title => 'Inventario';
|
||||||
@override String get searchHint => 'Buscar semillas';
|
@override String get searchHint => 'Buscar semillas';
|
||||||
@override String get empty => 'Aún no hay semillas. Toca + para añadir la primera.';
|
@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';
|
@override String get uncategorized => 'Sin categoría';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -673,6 +675,8 @@ extension on TranslationsEs {
|
||||||
'inventory.title' => 'Inventario',
|
'inventory.title' => 'Inventario',
|
||||||
'inventory.searchHint' => 'Buscar semillas',
|
'inventory.searchHint' => 'Buscar semillas',
|
||||||
'inventory.empty' => 'Aún no hay semillas. Toca + para añadir la primera.',
|
'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',
|
'inventory.uncategorized' => 'Sin categoría',
|
||||||
'quickAdd.title' => 'Añadir una semilla',
|
'quickAdd.title' => 'Añadir una semilla',
|
||||||
'quickAdd.labelField' => 'Nombre',
|
'quickAdd.labelField' => 'Nombre',
|
||||||
|
|
|
||||||
|
|
@ -4,40 +4,83 @@ import 'package:equatable/equatable.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
|
||||||
import '../data/variety_repository.dart';
|
import '../data/variety_repository.dart';
|
||||||
|
import '../db/enums.dart';
|
||||||
|
|
||||||
/// Inventory list state: all items from the DB plus the current search query.
|
/// 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.
|
/// 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 {
|
class InventoryState extends Equatable {
|
||||||
const InventoryState({
|
const InventoryState({
|
||||||
this.items = const [],
|
this.items = const [],
|
||||||
this.query = '',
|
this.query = '',
|
||||||
|
this.categoryFilter = const {},
|
||||||
|
this.typeFilter = const {},
|
||||||
this.loading = true,
|
this.loading = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
final List<VarietyListItem> items;
|
final List<VarietyListItem> items;
|
||||||
final String query;
|
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;
|
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 {
|
List<VarietyListItem> get visibleItems {
|
||||||
if (query.trim().isEmpty) return items;
|
final q = query.trim().toLowerCase();
|
||||||
final q = query.toLowerCase();
|
return items.where((i) {
|
||||||
return items.where((i) => i.label.toLowerCase().contains(q)).toList();
|
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({
|
InventoryState copyWith({
|
||||||
List<VarietyListItem>? items,
|
List<VarietyListItem>? items,
|
||||||
String? query,
|
String? query,
|
||||||
|
Set<String>? categoryFilter,
|
||||||
|
Set<LotType>? typeFilter,
|
||||||
bool? loading,
|
bool? loading,
|
||||||
}) {
|
}) {
|
||||||
return InventoryState(
|
return InventoryState(
|
||||||
items: items ?? this.items,
|
items: items ?? this.items,
|
||||||
query: query ?? this.query,
|
query: query ?? this.query,
|
||||||
|
categoryFilter: categoryFilter ?? this.categoryFilter,
|
||||||
|
typeFilter: typeFilter ?? this.typeFilter,
|
||||||
loading: loading ?? this.loading,
|
loading: loading ?? this.loading,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@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
|
/// 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));
|
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
|
@override
|
||||||
Future<void> close() async {
|
Future<void> close() async {
|
||||||
await _sub.cancel();
|
await _sub.cancel();
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,11 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import '../data/variety_repository.dart';
|
import '../data/variety_repository.dart';
|
||||||
|
import '../db/enums.dart';
|
||||||
import '../i18n/strings.g.dart';
|
import '../i18n/strings.g.dart';
|
||||||
import '../state/inventory_cubit.dart';
|
import '../state/inventory_cubit.dart';
|
||||||
import 'app_drawer.dart';
|
import 'app_drawer.dart';
|
||||||
|
import 'quantity_picker.dart';
|
||||||
import 'quick_add_sheet.dart';
|
import 'quick_add_sheet.dart';
|
||||||
import 'seed_glyph.dart';
|
import 'seed_glyph.dart';
|
||||||
import 'theme.dart';
|
import 'theme.dart';
|
||||||
|
|
@ -57,7 +59,16 @@ class InventoryListScreen extends StatelessWidget {
|
||||||
onChanged: context.read<InventoryCubit>().search,
|
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 {
|
class _InventoryBody extends StatelessWidget {
|
||||||
const _InventoryBody({required this.items});
|
const _InventoryBody({required this.items, this.filtered = false});
|
||||||
|
|
||||||
final List<VarietyListItem> items;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final t = context.t;
|
final t = context.t;
|
||||||
|
|
@ -88,7 +164,7 @@ class _InventoryBody extends StatelessWidget {
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
t.inventory.empty,
|
filtered ? t.inventory.noMatches : t.inventory.empty,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: Theme.of(context).textTheme.bodyLarge,
|
style: Theme.of(context).textTheme.bodyLarge,
|
||||||
),
|
),
|
||||||
|
|
@ -123,10 +199,10 @@ class _CategoryHeader extends StatelessWidget {
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 18, 16, 6),
|
padding: const EdgeInsets.fromLTRB(16, 18, 16, 6),
|
||||||
|
// Base on the text theme so it honours the system font-scale factor.
|
||||||
child: Text(
|
child: Text(
|
||||||
title,
|
title,
|
||||||
style: const TextStyle(
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
color: seedGreen,
|
color: seedGreen,
|
||||||
),
|
),
|
||||||
|
|
@ -157,7 +233,9 @@ class _VarietyTile extends StatelessWidget {
|
||||||
),
|
),
|
||||||
trailing: IconButton(
|
trailing: IconButton(
|
||||||
icon: const Icon(Icons.edit_outlined),
|
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,
|
tooltip: context.t.common.edit,
|
||||||
onPressed: open,
|
onPressed: open,
|
||||||
),
|
),
|
||||||
|
|
@ -175,20 +253,26 @@ class _Avatar extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final photo = item.photo;
|
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) {
|
if (photo != null) {
|
||||||
return ClipRRect(
|
return ExcludeSemantics(
|
||||||
borderRadius: BorderRadius.circular(8),
|
child: ClipRRect(
|
||||||
child: Image.memory(photo, width: 48, height: 48, fit: BoxFit.cover),
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Image.memory(photo, width: 48, height: 48, fit: BoxFit.cover),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
final trimmed = item.label.trim();
|
final trimmed = item.label.trim();
|
||||||
final initial = trimmed.isEmpty
|
final initial = trimmed.isEmpty
|
||||||
? '?'
|
? '?'
|
||||||
: trimmed.substring(0, 1).toUpperCase();
|
: trimmed.substring(0, 1).toUpperCase();
|
||||||
return CircleAvatar(
|
return ExcludeSemantics(
|
||||||
backgroundColor: seedAvatar,
|
child: CircleAvatar(
|
||||||
foregroundColor: seedOnAvatar,
|
backgroundColor: seedAvatar,
|
||||||
child: Text(initial),
|
foregroundColor: seedOnAvatar,
|
||||||
|
child: Text(initial),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:commons_core/commons_core.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:tane/db/database.dart';
|
import 'package:tane/db/database.dart';
|
||||||
|
import 'package:tane/db/enums.dart';
|
||||||
import 'package:tane/i18n/strings.g.dart';
|
import 'package:tane/i18n/strings.g.dart';
|
||||||
import 'package:tane/ui/inventory_list_screen.dart';
|
import 'package:tane/ui/inventory_list_screen.dart';
|
||||||
|
|
||||||
|
|
@ -48,7 +50,8 @@ void main() {
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('Grandma tomato'), findsOneWidget);
|
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);
|
await disposeTree(tester);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
@ -71,6 +74,120 @@ void main() {
|
||||||
await disposeTree(tester);
|
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 {
|
testWidgets('renders in Spanish when the locale is es', (tester) async {
|
||||||
final repo = newTestRepository(db);
|
final repo = newTestRepository(db);
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue