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

@ -25,6 +25,7 @@ class VarietyListItem extends Equatable {
this.needsReproduction = false,
this.isShared = false,
this.viability = SeedViability.unknown,
this.sowMonths,
});
final String id;
@ -62,6 +63,10 @@ class VarietyListItem extends Equatable {
/// or there is no reference figure.
final SeedViability viability;
/// The grower's recorded sow-months bitmask (see `domain/crop_calendar.dart`),
/// or null when unrecorded drives the "to sow this month" inventory filter.
final int? sowMonths;
@override
List<Object?> get props => [
id,
@ -75,6 +80,47 @@ class VarietyListItem extends Equatable {
needsReproduction,
isShared,
viability,
sowMonths,
];
}
/// A variety's whole recorded crop calendar, for the aggregate "what's due this
/// month" view. Each field is a 12-bit month mask (see `crop_calendar.dart`);
/// only varieties with at least one recorded phase are emitted.
class CalendarEntry extends Equatable {
const CalendarEntry({
required this.id,
required this.label,
this.category,
this.photo,
this.sowMonths,
this.transplantMonths,
this.floweringMonths,
this.fruitingMonths,
this.seedHarvestMonths,
});
final String id;
final String label;
final String? category;
final Uint8List? photo;
final int? sowMonths;
final int? transplantMonths;
final int? floweringMonths;
final int? fruitingMonths;
final int? seedHarvestMonths;
@override
List<Object?> get props => [
id,
label,
category,
photo,
sowMonths,
transplantMonths,
floweringMonths,
fruitingMonths,
seedHarvestMonths,
];
}
@ -606,11 +652,57 @@ class VarietyRepository {
: speciesViability[v.speciesId],
currentYear: currentYear,
),
sowMonths: v.sowMonths,
),
)
.toList();
}
/// Emits every non-draft variety that has any recorded crop-calendar phase,
/// for the aggregate "what's due this month" screen. Re-emits on variety or
/// photo changes.
Stream<List<CalendarEntry>> watchCalendar() {
final triggers = StreamGroup.merge<void>([
(_db.select(
_db.varieties,
)..where((v) => v.isDeleted.equals(false))).watch().map((_) {}),
_db.select(_db.attachments).watch().map((_) {}),
]);
return triggers.asyncMap((_) => _loadCalendar());
}
Future<List<CalendarEntry>> _loadCalendar() async {
final rows =
await (_db.select(_db.varieties)
..where(
(v) =>
v.isDeleted.equals(false) &
v.isDraft.equals(false) &
(v.sowMonths.isNotNull() |
v.transplantMonths.isNotNull() |
v.floweringMonths.isNotNull() |
v.fruitingMonths.isNotNull() |
v.seedHarvestMonths.isNotNull()),
)
..orderBy([(v) => OrderingTerm(expression: v.label)]))
.get();
final photos = await _firstPhotosFor(rows.map((v) => v.id).toList());
return [
for (final v in rows)
CalendarEntry(
id: v.id,
label: v.label,
category: v.category,
photo: photos[v.id],
sowMonths: v.sowMonths,
transplantMonths: v.transplantMonths,
floweringMonths: v.floweringMonths,
fruitingMonths: v.fruitingMonths,
seedHarvestMonths: v.seedHarvestMonths,
),
];
}
/// Maps each of [speciesIds] to its bundled viability figure (years); species
/// without a figure are simply absent from the map.
Future<Map<String, int>> _speciesViabilityFor(Set<String> speciesIds) async {