feat(calendar): "what's due this month" — dedicated screen + inventory filter

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.
This commit is contained in:
vjrj 2026-07-11 06:27:57 +02:00
parent 28e8318026
commit 171daabce3
19 changed files with 681 additions and 5 deletions

View file

@ -5,6 +5,7 @@ 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
@ -19,6 +20,8 @@ class InventoryState extends Equatable {
this.organicOnly = false,
this.needsReproductionOnly = false,
this.sharingOnly = false,
this.sowThisMonthOnly = false,
this.filterMonth = 0,
this.loading = true,
this.selectionMode = false,
this.selectedIds = const {},
@ -49,6 +52,15 @@ class InventoryState extends Equatable {
/// "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
@ -84,10 +96,17 @@ class InventoryState extends Equatable {
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);
@ -109,6 +128,8 @@ class InventoryState extends Equatable {
bool? organicOnly,
bool? needsReproductionOnly,
bool? sharingOnly,
bool? sowThisMonthOnly,
int? filterMonth,
bool? loading,
bool? selectionMode,
Set<String>? selectedIds,
@ -123,6 +144,8 @@ class InventoryState extends Equatable {
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,
@ -139,6 +162,8 @@ class InventoryState extends Equatable {
organicOnly,
needsReproductionOnly,
sharingOnly,
sowThisMonthOnly,
filterMonth,
loading,
selectionMode,
selectedIds,
@ -148,7 +173,9 @@ class InventoryState extends Equatable {
/// 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) : super(const 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(
@ -159,6 +186,7 @@ class InventoryCubit extends Cubit<InventoryState> {
}
final VarietyRepository _repo;
final int Function() _nowMonth;
late final StreamSubscription<
({List<VarietyListItem> items, List<VarietyListItem> drafts})
>
@ -192,6 +220,12 @@ class InventoryCubit extends Cubit<InventoryState> {
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 {}));
@ -230,6 +264,7 @@ class InventoryCubit extends Cubit<InventoryState> {
organicOnly: false,
needsReproductionOnly: false,
sharingOnly: false,
sowThisMonthOnly: false,
),
);