Reuses the existing per-variety crop-calendar masks (sow/transplant/ flowering/fruiting/seed-harvest) — no schema change, no migration — to answer "qué toca este mes" across the whole inventory. Since the months are the grower's own record, there's no hemisphere assumption. - Repo: CalendarEntry + watchCalendar() (varieties with any recorded phase); VarietyListItem now carries sowMonths. - Dedicated CalendarScreen (/calendar): a month strip (defaults to the current month, intl month names) and varieties grouped by phase — actions (sow/transplant/harvest seed) above informational (flowering/ fruiting), each tinted, tap → variety detail; empty-month state. - Inventory filter: a 'this month' chip (shown only when some variety has a calendar) keeps what sows in the current month; folded into the attributes group + clear-filters. - Entry points: a live Home card + a drawer item. - i18n calendar block + menu.calendar (en/es/pt/ast). - Tests: calendar_test (watchCalendar, list carries sow-months, pure filter), calendar_screen_test (phase grouping, month switch, empty), plus the screen added to the small-screen overflow guard.
276 lines
9.2 KiB
Dart
276 lines
9.2 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:equatable/equatable.dart';
|
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
|
|
import '../data/variety_repository.dart';
|
|
import '../db/enums.dart';
|
|
import '../domain/crop_calendar.dart';
|
|
|
|
/// 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.drafts = const [],
|
|
this.query = '',
|
|
this.categoryFilter = const {},
|
|
this.typeFilter = const {},
|
|
this.organicOnly = false,
|
|
this.needsReproductionOnly = false,
|
|
this.sharingOnly = false,
|
|
this.sowThisMonthOnly = false,
|
|
this.filterMonth = 0,
|
|
this.loading = true,
|
|
this.selectionMode = false,
|
|
this.selectedIds = const {},
|
|
});
|
|
|
|
final List<VarietyListItem> items;
|
|
|
|
/// Photo-first captures still awaiting a name — the "to catalogue" tray,
|
|
/// shown above the list and never mixed into [items].
|
|
final List<VarietyListItem> drafts;
|
|
|
|
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;
|
|
|
|
/// When true, keep only varieties flagged organic ("eco").
|
|
final bool organicOnly;
|
|
|
|
/// When true, keep only varieties flagged "needs reproducing this season".
|
|
final bool needsReproductionOnly;
|
|
|
|
/// When true, keep only varieties with some lot offered to others — the
|
|
/// "what I share" view.
|
|
final bool sharingOnly;
|
|
|
|
/// When true, keep only varieties whose recorded sow-months include
|
|
/// [filterMonth] — the inventory "to sow this month" view.
|
|
final bool sowThisMonthOnly;
|
|
|
|
/// The month (1..12) the "to sow this month" filter matches against — set to
|
|
/// the current month by the cubit when the filter is turned on. Only read
|
|
/// while [sowThisMonthOnly] is true.
|
|
final int filterMonth;
|
|
|
|
final bool loading;
|
|
|
|
/// Whether the list is in multi-select mode — used to pick a subset of the
|
|
/// inventory to print labels for.
|
|
final bool selectionMode;
|
|
|
|
/// Ids of the varieties currently selected in [selectionMode].
|
|
final Set<String> selectedIds;
|
|
|
|
/// 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 {
|
|
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;
|
|
}
|
|
if (organicOnly && !i.isOrganic) return false;
|
|
if (needsReproductionOnly && !i.needsReproduction) return false;
|
|
if (sharingOnly && !i.isShared) return false;
|
|
if (sowThisMonthOnly && !maskHasMonth(i.sowMonths, filterMonth)) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}).toList();
|
|
}
|
|
|
|
/// Whether any variety has recorded sow-months, so the UI only offers the
|
|
/// "to sow this month" chip when it would match something.
|
|
bool get hasSowCalendar => items.any((i) => i.sowMonths != null);
|
|
|
|
/// Whether any item is flagged organic, so the UI only offers the eco filter
|
|
/// chip when it would actually match something.
|
|
bool get hasOrganic => items.any((i) => i.isOrganic);
|
|
|
|
/// Whether any item is flagged "needs reproducing", so the UI only offers
|
|
/// that filter chip when it would match something.
|
|
bool get hasNeedsReproduction => items.any((i) => i.needsReproduction);
|
|
|
|
/// Whether any variety has a lot offered to others, so the UI only offers
|
|
/// the "sharing" chip and the printable catalog when they'd show something.
|
|
bool get hasShared => items.any((i) => i.isShared);
|
|
|
|
InventoryState copyWith({
|
|
List<VarietyListItem>? items,
|
|
List<VarietyListItem>? drafts,
|
|
String? query,
|
|
Set<String>? categoryFilter,
|
|
Set<LotType>? typeFilter,
|
|
bool? organicOnly,
|
|
bool? needsReproductionOnly,
|
|
bool? sharingOnly,
|
|
bool? sowThisMonthOnly,
|
|
int? filterMonth,
|
|
bool? loading,
|
|
bool? selectionMode,
|
|
Set<String>? selectedIds,
|
|
}) {
|
|
return InventoryState(
|
|
items: items ?? this.items,
|
|
drafts: drafts ?? this.drafts,
|
|
query: query ?? this.query,
|
|
categoryFilter: categoryFilter ?? this.categoryFilter,
|
|
typeFilter: typeFilter ?? this.typeFilter,
|
|
organicOnly: organicOnly ?? this.organicOnly,
|
|
needsReproductionOnly:
|
|
needsReproductionOnly ?? this.needsReproductionOnly,
|
|
sharingOnly: sharingOnly ?? this.sharingOnly,
|
|
sowThisMonthOnly: sowThisMonthOnly ?? this.sowThisMonthOnly,
|
|
filterMonth: filterMonth ?? this.filterMonth,
|
|
loading: loading ?? this.loading,
|
|
selectionMode: selectionMode ?? this.selectionMode,
|
|
selectedIds: selectedIds ?? this.selectedIds,
|
|
);
|
|
}
|
|
|
|
@override
|
|
List<Object?> get props => [
|
|
items,
|
|
drafts,
|
|
query,
|
|
categoryFilter,
|
|
typeFilter,
|
|
organicOnly,
|
|
needsReproductionOnly,
|
|
sharingOnly,
|
|
sowThisMonthOnly,
|
|
filterMonth,
|
|
loading,
|
|
selectionMode,
|
|
selectedIds,
|
|
];
|
|
}
|
|
|
|
/// Subscribes to the repository's reactive inventory stream. The list updates
|
|
/// automatically after a quick-add — no manual refresh.
|
|
class InventoryCubit extends Cubit<InventoryState> {
|
|
InventoryCubit(this._repo, {int Function()? nowMonth})
|
|
: _nowMonth = nowMonth ?? (() => DateTime.now().month),
|
|
super(const InventoryState()) {
|
|
// One combined subscription (list + draft tray). Two separate StreamGroups
|
|
// here re-emit in a loop that hangs widget tests — see watchInventoryView.
|
|
_sub = _repo.watchInventoryView().listen(
|
|
(view) => emit(
|
|
state.copyWith(items: view.items, drafts: view.drafts, loading: false),
|
|
),
|
|
);
|
|
}
|
|
|
|
final VarietyRepository _repo;
|
|
final int Function() _nowMonth;
|
|
late final StreamSubscription<
|
|
({List<VarietyListItem> items, List<VarietyListItem> drafts})
|
|
>
|
|
_sub;
|
|
|
|
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));
|
|
}
|
|
|
|
/// Toggles the "organic only" filter.
|
|
void toggleOrganicOnly() =>
|
|
emit(state.copyWith(organicOnly: !state.organicOnly));
|
|
|
|
/// Toggles the "needs reproducing only" filter.
|
|
void toggleNeedsReproductionOnly() =>
|
|
emit(state.copyWith(needsReproductionOnly: !state.needsReproductionOnly));
|
|
|
|
/// Toggles the "what I share" filter.
|
|
void toggleSharingOnly() =>
|
|
emit(state.copyWith(sharingOnly: !state.sharingOnly));
|
|
|
|
/// Toggles the "to sow this month" filter, pinned to the current month.
|
|
void toggleSowThisMonth() => emit(state.copyWith(
|
|
sowThisMonthOnly: !state.sowThisMonthOnly,
|
|
filterMonth: _nowMonth(),
|
|
));
|
|
|
|
/// Enters multi-select mode with an empty selection (from the toolbar).
|
|
void startSelection() =>
|
|
emit(state.copyWith(selectionMode: true, selectedIds: const {}));
|
|
|
|
/// Enters multi-select mode with [id] as the first selected variety
|
|
/// (triggered by a long-press on a tile).
|
|
void enterSelection(String id) =>
|
|
emit(state.copyWith(selectionMode: true, selectedIds: {id}));
|
|
|
|
/// Toggles a variety in the selection (add if absent, remove if present).
|
|
/// An empty selection stays in selection mode; the user exits explicitly.
|
|
void toggleSelection(String id) {
|
|
final next = Set<String>.of(state.selectedIds);
|
|
if (!next.remove(id)) next.add(id);
|
|
emit(state.copyWith(selectionMode: true, selectedIds: next));
|
|
}
|
|
|
|
/// Selects every currently visible variety, so "filter to a category, then
|
|
/// select all" is the way to pick a subset of the inventory to print.
|
|
void selectAllVisible() => emit(
|
|
state.copyWith(
|
|
selectionMode: true,
|
|
selectedIds: state.visibleItems.map((i) => i.id).toSet(),
|
|
),
|
|
);
|
|
|
|
/// Leaves selection mode and clears the selection.
|
|
void exitSelection() =>
|
|
emit(state.copyWith(selectionMode: false, selectedIds: const {}));
|
|
|
|
/// Clears all filters (search is left untouched).
|
|
void clearFilters() => emit(
|
|
state.copyWith(
|
|
categoryFilter: const {},
|
|
typeFilter: const {},
|
|
organicOnly: false,
|
|
needsReproductionOnly: false,
|
|
sharingOnly: false,
|
|
sowThisMonthOnly: false,
|
|
),
|
|
);
|
|
|
|
@override
|
|
Future<void> close() async {
|
|
await _sub.cancel();
|
|
return super.close();
|
|
}
|
|
}
|