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

@ -27,6 +27,7 @@ import 'state/inventory_cubit.dart';
import 'state/variety_detail_cubit.dart';
import 'ui/about_screen.dart';
import 'ui/auto_backup_gate.dart';
import 'ui/calendar_screen.dart';
import 'ui/chat_list_screen.dart';
import 'ui/chat_screen.dart';
import 'ui/home_screen.dart';
@ -266,6 +267,12 @@ class TaneApp extends StatelessWidget {
path: '/sales',
builder: (context, state) => const SalesScreen(),
),
// "What's due this month" across the inventory reads the per-variety
// crop calendar, no network.
GoRoute(
path: '/calendar',
builder: (context, state) => const CalendarScreen(),
),
GoRoute(
path: '/variety/:id',
builder: (context, state) => BlocProvider(

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 {

View file

@ -1,4 +1,10 @@
{
"calendar": {
"title": "Esti mes",
"homeSubtitle": "Qué semar y facer agora",
"filterChip": "Esti mes",
"nothing": "Nada anotao pa {month}."
},
"app": {
"title": "Tanemaki"
},
@ -38,6 +44,7 @@
"following": "Siguiendo",
"plantares": "Plantares",
"sales": "Ventes",
"calendar": "Calendariu",
"settings": "Axustes"
},
"settings": {

View file

@ -1,4 +1,10 @@
{
"calendar": {
"title": "This month",
"homeSubtitle": "What to sow and do now",
"filterChip": "This month",
"nothing": "Nothing noted for {month}."
},
"app": {
"title": "Tanemaki"
},
@ -39,6 +45,7 @@
"following": "Following",
"plantares": "Plantares",
"sales": "Sales",
"calendar": "Calendar",
"settings": "Settings"
},
"settings": {

View file

@ -1,4 +1,10 @@
{
"calendar": {
"title": "Este mes",
"homeSubtitle": "Qué sembrar y hacer ahora",
"filterChip": "Este mes",
"nothing": "Nada anotado para {month}."
},
"app": {
"title": "Tanemaki"
},
@ -39,6 +45,7 @@
"following": "Siguiendo",
"plantares": "Plantares",
"sales": "Ventas",
"calendar": "Calendario",
"settings": "Ajustes"
},
"settings": {

View file

@ -1,4 +1,10 @@
{
"calendar": {
"title": "Este mês",
"homeSubtitle": "O que semear e fazer agora",
"filterChip": "Este mês",
"nothing": "Nada anotado para {month}."
},
"app": {
"title": "Tanemaki"
},
@ -39,6 +45,7 @@
"following": "A seguir",
"plantares": "Plantares",
"sales": "Vendas",
"calendar": "Calendário",
"settings": "Definições"
},
"settings": {

View file

@ -4,9 +4,9 @@
/// To regenerate, run: `dart run slang`
///
/// Locales: 4
/// Strings: 1832 (458 per locale)
/// Strings: 1852 (463 per locale)
///
/// Built on 2026-07-11 at 04:03 UTC
/// Built on 2026-07-11 at 04:24 UTC
// coverage:ignore-file
// ignore_for_file: type=lint, unused_import

View file

@ -39,6 +39,7 @@ class TranslationsAst extends Translations with BaseTranslations<AppLocale, Tran
TranslationsAst $copyWith({TranslationMetadata<AppLocale, Translations>? meta}) => TranslationsAst(meta: meta ?? this.$meta);
// Translations
@override late final _Translations$calendar$ast calendar = _Translations$calendar$ast._(_root);
@override late final _Translations$app$ast app = _Translations$app$ast._(_root);
@override late final _Translations$bootstrap$ast bootstrap = _Translations$bootstrap$ast._(_root);
@override late final _Translations$common$ast common = _Translations$common$ast._(_root);
@ -81,6 +82,19 @@ class TranslationsAst extends Translations with BaseTranslations<AppLocale, Tran
@override late final _Translations$sale$ast sale = _Translations$sale$ast._(_root);
}
// Path: calendar
class _Translations$calendar$ast extends Translations$calendar$en {
_Translations$calendar$ast._(TranslationsAst root) : this._root = root, super.internal(root);
final TranslationsAst _root; // ignore: unused_field
// Translations
@override String get title => 'Esti mes';
@override String get homeSubtitle => 'Qué semar y facer agora';
@override String get filterChip => 'Esti mes';
@override String nothing({required Object month}) => 'Nada anotao pa ${month}.';
}
// Path: app
class _Translations$app$ast extends Translations$app$en {
_Translations$app$ast._(TranslationsAst root) : this._root = root, super.internal(root);
@ -161,6 +175,7 @@ class _Translations$menu$ast extends Translations$menu$en {
@override String get following => 'Siguiendo';
@override String get plantares => 'Plantares';
@override String get sales => 'Ventes';
@override String get calendar => 'Calendariu';
@override String get settings => 'Axustes';
}
@ -1208,6 +1223,10 @@ class _Translations$intro$slides$plantare$ast extends Translations$intro$slides$
extension on TranslationsAst {
dynamic _flatMapFunction(String path) {
return switch (path) {
'calendar.title' => 'Esti mes',
'calendar.homeSubtitle' => 'Qué semar y facer agora',
'calendar.filterChip' => 'Esti mes',
'calendar.nothing' => ({required Object month}) => 'Nada anotao pa ${month}.',
'app.title' => 'Tanemaki',
'bootstrap.failed' => 'Tanemaki nun pudo aniciar',
'bootstrap.retry' => 'Reintentar',
@ -1236,6 +1255,7 @@ extension on TranslationsAst {
'menu.following' => 'Siguiendo',
'menu.plantares' => 'Plantares',
'menu.sales' => 'Ventes',
'menu.calendar' => 'Calendariu',
'menu.settings' => 'Axustes',
'settings.language' => 'Llingua',
'settings.systemLanguage' => 'Llingua del sistema',

View file

@ -40,6 +40,7 @@ class Translations with BaseTranslations<AppLocale, Translations> {
Translations $copyWith({TranslationMetadata<AppLocale, Translations>? meta}) => Translations(meta: meta ?? this.$meta);
// Translations
late final Translations$calendar$en calendar = Translations$calendar$en.internal(_root);
late final Translations$app$en app = Translations$app$en.internal(_root);
late final Translations$bootstrap$en bootstrap = Translations$bootstrap$en.internal(_root);
late final Translations$common$en common = Translations$common$en.internal(_root);
@ -82,6 +83,27 @@ class Translations with BaseTranslations<AppLocale, Translations> {
late final Translations$sale$en sale = Translations$sale$en.internal(_root);
}
// Path: calendar
class Translations$calendar$en {
Translations$calendar$en.internal(this._root);
final Translations _root; // ignore: unused_field
// Translations
/// en: 'This month'
String get title => 'This month';
/// en: 'What to sow and do now'
String get homeSubtitle => 'What to sow and do now';
/// en: 'This month'
String get filterChip => 'This month';
/// en: 'Nothing noted for {month}.'
String nothing({required Object month}) => 'Nothing noted for ${month}.';
}
// Path: app
class Translations$app$en {
Translations$app$en.internal(this._root);
@ -222,6 +244,9 @@ class Translations$menu$en {
/// en: 'Sales'
String get sales => 'Sales';
/// en: 'Calendar'
String get calendar => 'Calendar';
/// en: 'Settings'
String get settings => 'Settings';
}
@ -2111,6 +2136,10 @@ class Translations$intro$slides$plantare$en {
extension on Translations {
dynamic _flatMapFunction(String path) {
return switch (path) {
'calendar.title' => 'This month',
'calendar.homeSubtitle' => 'What to sow and do now',
'calendar.filterChip' => 'This month',
'calendar.nothing' => ({required Object month}) => 'Nothing noted for ${month}.',
'app.title' => 'Tanemaki',
'bootstrap.failed' => 'Tanemaki couldn\'t start',
'bootstrap.retry' => 'Try again',
@ -2140,6 +2169,7 @@ extension on Translations {
'menu.following' => 'Following',
'menu.plantares' => 'Plantares',
'menu.sales' => 'Sales',
'menu.calendar' => 'Calendar',
'menu.settings' => 'Settings',
'settings.language' => 'Language',
'settings.systemLanguage' => 'System language',

View file

@ -39,6 +39,7 @@ class TranslationsEs extends Translations with BaseTranslations<AppLocale, Trans
TranslationsEs $copyWith({TranslationMetadata<AppLocale, Translations>? meta}) => TranslationsEs(meta: meta ?? this.$meta);
// Translations
@override late final _Translations$calendar$es calendar = _Translations$calendar$es._(_root);
@override late final _Translations$app$es app = _Translations$app$es._(_root);
@override late final _Translations$bootstrap$es bootstrap = _Translations$bootstrap$es._(_root);
@override late final _Translations$common$es common = _Translations$common$es._(_root);
@ -81,6 +82,19 @@ class TranslationsEs extends Translations with BaseTranslations<AppLocale, Trans
@override late final _Translations$sale$es sale = _Translations$sale$es._(_root);
}
// Path: calendar
class _Translations$calendar$es extends Translations$calendar$en {
_Translations$calendar$es._(TranslationsEs root) : this._root = root, super.internal(root);
final TranslationsEs _root; // ignore: unused_field
// Translations
@override String get title => 'Este mes';
@override String get homeSubtitle => 'Qué sembrar y hacer ahora';
@override String get filterChip => 'Este mes';
@override String nothing({required Object month}) => 'Nada anotado para ${month}.';
}
// Path: app
class _Translations$app$es extends Translations$app$en {
_Translations$app$es._(TranslationsEs root) : this._root = root, super.internal(root);
@ -162,6 +176,7 @@ class _Translations$menu$es extends Translations$menu$en {
@override String get following => 'Siguiendo';
@override String get plantares => 'Plantares';
@override String get sales => 'Ventas';
@override String get calendar => 'Calendario';
@override String get settings => 'Ajustes';
}
@ -1210,6 +1225,10 @@ class _Translations$intro$slides$plantare$es extends Translations$intro$slides$p
extension on TranslationsEs {
dynamic _flatMapFunction(String path) {
return switch (path) {
'calendar.title' => 'Este mes',
'calendar.homeSubtitle' => 'Qué sembrar y hacer ahora',
'calendar.filterChip' => 'Este mes',
'calendar.nothing' => ({required Object month}) => 'Nada anotado para ${month}.',
'app.title' => 'Tanemaki',
'bootstrap.failed' => 'Tanemaki no pudo arrancar',
'bootstrap.retry' => 'Reintentar',
@ -1239,6 +1258,7 @@ extension on TranslationsEs {
'menu.following' => 'Siguiendo',
'menu.plantares' => 'Plantares',
'menu.sales' => 'Ventas',
'menu.calendar' => 'Calendario',
'menu.settings' => 'Ajustes',
'settings.language' => 'Idioma',
'settings.systemLanguage' => 'Idioma del sistema',

View file

@ -39,6 +39,7 @@ class TranslationsPt extends Translations with BaseTranslations<AppLocale, Trans
TranslationsPt $copyWith({TranslationMetadata<AppLocale, Translations>? meta}) => TranslationsPt(meta: meta ?? this.$meta);
// Translations
@override late final _Translations$calendar$pt calendar = _Translations$calendar$pt._(_root);
@override late final _Translations$app$pt app = _Translations$app$pt._(_root);
@override late final _Translations$bootstrap$pt bootstrap = _Translations$bootstrap$pt._(_root);
@override late final _Translations$common$pt common = _Translations$common$pt._(_root);
@ -81,6 +82,19 @@ class TranslationsPt extends Translations with BaseTranslations<AppLocale, Trans
@override late final _Translations$sale$pt sale = _Translations$sale$pt._(_root);
}
// Path: calendar
class _Translations$calendar$pt extends Translations$calendar$en {
_Translations$calendar$pt._(TranslationsPt root) : this._root = root, super.internal(root);
final TranslationsPt _root; // ignore: unused_field
// Translations
@override String get title => 'Este mês';
@override String get homeSubtitle => 'O que semear e fazer agora';
@override String get filterChip => 'Este mês';
@override String nothing({required Object month}) => 'Nada anotado para ${month}.';
}
// Path: app
class _Translations$app$pt extends Translations$app$en {
_Translations$app$pt._(TranslationsPt root) : this._root = root, super.internal(root);
@ -162,6 +176,7 @@ class _Translations$menu$pt extends Translations$menu$en {
@override String get following => 'A seguir';
@override String get plantares => 'Plantares';
@override String get sales => 'Vendas';
@override String get calendar => 'Calendário';
@override String get settings => 'Definições';
}
@ -1207,6 +1222,10 @@ class _Translations$intro$slides$plantare$pt extends Translations$intro$slides$p
extension on TranslationsPt {
dynamic _flatMapFunction(String path) {
return switch (path) {
'calendar.title' => 'Este mês',
'calendar.homeSubtitle' => 'O que semear e fazer agora',
'calendar.filterChip' => 'Este mês',
'calendar.nothing' => ({required Object month}) => 'Nada anotado para ${month}.',
'app.title' => 'Tanemaki',
'bootstrap.failed' => 'O Tanemaki não conseguiu iniciar',
'bootstrap.retry' => 'Tentar de novo',
@ -1236,6 +1255,7 @@ extension on TranslationsPt {
'menu.following' => 'A seguir',
'menu.plantares' => 'Plantares',
'menu.sales' => 'Vendas',
'menu.calendar' => 'Calendário',
'menu.settings' => 'Definições',
'settings.language' => 'Idioma',
'settings.systemLanguage' => 'Idioma do sistema',

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,
),
);

View file

@ -53,6 +53,15 @@ class AppDrawer extends StatelessWidget {
context.push('/sales');
},
),
// "What's due this month" the aggregate crop calendar, offline.
_DrawerItem(
icon: const Icon(Icons.calendar_month),
label: t.menu.calendar,
onTap: () {
Navigator.of(context).pop();
context.push('/calendar');
},
),
_DrawerItem(
icon: const Icon(Symbols.storefront),
label: t.menu.market,

View file

@ -0,0 +1,247 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import '../app.dart' show materialLocaleFor;
import '../data/variety_repository.dart';
import '../domain/crop_calendar.dart';
import '../i18n/strings.g.dart';
import 'category_palette.dart';
import 'theme.dart';
/// One crop-calendar phase as shown on the "this month" screen: its label,
/// which month mask on a [CalendarEntry] it reads, an icon and a tint.
class _Phase {
const _Phase(this.label, this.maskOf, this.icon, this.color);
final String Function(Translations t) label;
final int? Function(CalendarEntry e) maskOf;
final IconData icon;
final Color color;
}
// Actions first (sow / transplant / harvest seed), then the informational
// phases (flowering / fruiting) "qué toca hacer" above "qué está pasando".
const _phases = <_Phase>[
_Phase(_sowLabel, _sowMask, Icons.grass, Color(0xFF2F7D34)),
_Phase(_transplantLabel, _transplantMask, Icons.spa, Color(0xFF3E7168)),
_Phase(_harvestLabel, _harvestMask, Icons.grain, Color(0xFF8A6D1E)),
_Phase(_floweringLabel, _floweringMask, Icons.local_florist, Color(0xFF9B5566)),
_Phase(_fruitingLabel, _fruitingMask, Icons.eco, Color(0xFF9C5B3B)),
];
String _sowLabel(Translations t) => t.cropCalendar.sow;
String _transplantLabel(Translations t) => t.cropCalendar.transplant;
String _harvestLabel(Translations t) => t.cropCalendar.seedHarvest;
String _floweringLabel(Translations t) => t.cropCalendar.flowering;
String _fruitingLabel(Translations t) => t.cropCalendar.fruiting;
int? _sowMask(CalendarEntry e) => e.sowMonths;
int? _transplantMask(CalendarEntry e) => e.transplantMonths;
int? _harvestMask(CalendarEntry e) => e.seedHarvestMonths;
int? _floweringMask(CalendarEntry e) => e.floweringMonths;
int? _fruitingMask(CalendarEntry e) => e.fruitingMonths;
/// "What's due this month" across the whole inventory: a month strip plus the
/// varieties whose recorded calendar has an action in the picked month, grouped
/// by phase. Reuses the per-variety calendar data no schema of its own.
class CalendarScreen extends StatefulWidget {
const CalendarScreen({this.initialMonth, super.key});
/// The month (1..12) to open on; defaults to the current month. Injected in
/// tests so they don't depend on the wall clock.
final int? initialMonth;
@override
State<CalendarScreen> createState() => _CalendarScreenState();
}
class _CalendarScreenState extends State<CalendarScreen> {
late int _month = widget.initialMonth ?? DateTime.now().month;
@override
Widget build(BuildContext context) {
final t = context.t;
final repo = context.read<VarietyRepository>();
return Scaffold(
appBar: AppBar(title: Text(t.calendar.title)),
body: Column(
children: [
_MonthStrip(
month: _month,
onSelect: (m) => setState(() => _month = m),
),
const Divider(height: 1),
Expanded(
child: StreamBuilder<List<CalendarEntry>>(
stream: repo.watchCalendar(),
builder: (context, snapshot) {
final entries = snapshot.data ?? const <CalendarEntry>[];
final sections = <Widget>[];
for (final phase in _phases) {
final matches = [
for (final e in entries)
if (maskHasMonth(phase.maskOf(e), _month)) e,
];
if (matches.isEmpty) continue;
sections.add(_PhaseGroup(phase: phase, entries: matches));
}
if (sections.isEmpty) {
return _EmptyMonth(monthName: _monthName(context, _month));
}
return ListView(
padding: const EdgeInsets.only(bottom: 24),
children: sections,
);
},
),
),
],
),
);
}
}
/// A horizontally scrolling strip of the twelve months; the picked one is
/// filled. Scrolls the picked month into view on first build.
class _MonthStrip extends StatelessWidget {
const _MonthStrip({required this.month, required this.onSelect});
final int month;
final ValueChanged<int> onSelect;
@override
Widget build(BuildContext context) {
final locale = materialLocaleFor(Localizations.localeOf(context));
final fmt = DateFormat.MMM(locale.toLanguageTag());
return SizedBox(
height: 56,
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
itemCount: 12,
separatorBuilder: (_, _) => const SizedBox(width: 8),
itemBuilder: (context, i) {
final m = i + 1;
final selected = m == month;
return ChoiceChip(
key: Key('calendar.month.$m'),
label: Text(_capitalise(fmt.format(DateTime(2000, m)))),
selected: selected,
onSelected: (_) => onSelect(m),
selectedColor: seedPrimaryContainer,
labelStyle: TextStyle(
color: selected ? seedOnPrimaryContainer : seedMuted,
fontWeight: selected ? FontWeight.w600 : FontWeight.w400,
),
);
},
),
);
}
}
class _PhaseGroup extends StatelessWidget {
const _PhaseGroup({required this.phase, required this.entries});
final _Phase phase;
final List<CalendarEntry> entries;
@override
Widget build(BuildContext context) {
final t = context.t;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 18, 16, 6),
child: Row(
children: [
Icon(phase.icon, size: 20, color: phase.color),
const SizedBox(width: 8),
Text(
phase.label(t),
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: phase.color,
),
),
const SizedBox(width: 6),
Text('· ${entries.length}',
style: const TextStyle(color: seedMuted)),
],
),
),
for (final e in entries) _CalendarRow(entry: e),
],
);
}
}
class _CalendarRow extends StatelessWidget {
const _CalendarRow({required this.entry});
final CalendarEntry entry;
@override
Widget build(BuildContext context) {
final swatch = categorySwatch(entry.category ?? '');
return ListTile(
key: Key('calendar.entry.${entry.id}'),
leading: CircleAvatar(
radius: 20,
backgroundColor: swatch.fill,
foregroundImage:
entry.photo == null ? null : MemoryImage(entry.photo!),
child: entry.photo == null
? Text(
entry.label.isEmpty
? '?'
: entry.label.substring(0, 1).toUpperCase(),
style: TextStyle(color: swatch.ink, fontWeight: FontWeight.w600),
)
: null,
),
title: Text(entry.label),
subtitle: entry.category == null ? null : Text(entry.category!),
onTap: () => context.push('/variety/${entry.id}'),
);
}
}
class _EmptyMonth extends StatelessWidget {
const _EmptyMonth({required this.monthName});
final String monthName;
@override
Widget build(BuildContext context) {
final t = context.t;
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.event_available,
size: 64, color: seedGreen.withValues(alpha: 0.5)),
const SizedBox(height: 16),
Text(
t.calendar.nothing(month: monthName),
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge,
),
],
),
),
);
}
}
String _monthName(BuildContext context, int month) {
final locale = materialLocaleFor(Localizations.localeOf(context));
return _capitalise(
DateFormat.MMMM(locale.toLanguageTag()).format(DateTime(2000, month)));
}
String _capitalise(String s) =>
s.isEmpty ? s : s[0].toUpperCase() + s.substring(1);

View file

@ -102,6 +102,14 @@ class HomeScreen extends StatelessWidget {
onTap: () => context.push('/inventory'),
),
const SizedBox(height: 16),
_OutlinedMenuCard(
key: const Key('home.calendar'),
icon: Icons.calendar_month,
label: t.calendar.title,
subtitle: t.calendar.homeSubtitle,
onTap: () => context.push('/calendar'),
),
const SizedBox(height: 16),
_OutlinedMenuCard(
key: const Key('home.market'),
icon: Icons.storefront_outlined,

View file

@ -286,11 +286,14 @@ class _FilterBar extends StatelessWidget {
final hasNeedsReproduction = state.hasNeedsReproduction;
// Only offer the "I share" chip when some lot is marked to share.
final hasShared = state.hasShared;
// Only offer the "to sow this month" chip when some variety has a calendar.
final hasSowCalendar = state.hasSowCalendar;
if (categories.isEmpty &&
forms.isEmpty &&
!hasOrganic &&
!hasNeedsReproduction &&
!hasShared) {
!hasShared &&
!hasSowCalendar) {
return const SizedBox.shrink();
}
@ -298,6 +301,18 @@ class _FilterBar extends StatelessWidget {
// attributes (green icons) · forms (per-form tonality) · families (each a
// stable earthy tonality). Grouping + colour makes a long row scannable.
final attrChips = <Widget>[
if (hasSowCalendar)
FilterChip(
key: const Key('inventory.filter.sowThisMonth'),
avatar: Icon(
Icons.calendar_month,
size: 18,
color: state.sowThisMonthOnly ? null : seedGreen,
),
label: Text(t.calendar.filterChip),
selected: state.sowThisMonthOnly,
onSelected: (_) => cubit.toggleSowThisMonth(),
),
if (hasShared)
FilterChip(
key: const Key('inventory.filter.sharing'),
@ -361,7 +376,8 @@ class _FilterBar extends StatelessWidget {
state.typeFilter.isNotEmpty ||
state.organicOnly ||
state.needsReproductionOnly ||
state.sharingOnly;
state.sharingOnly ||
state.sowThisMonthOnly;
// Lay out the groups in order, dropping a hairline divider between any two
// that both have chips.

View file

@ -0,0 +1,65 @@
import 'package:drift/drift.dart' show Value;
import 'package:flutter_test/flutter_test.dart';
import 'package:tane/data/variety_repository.dart';
import 'package:tane/db/database.dart';
import 'package:tane/domain/crop_calendar.dart';
import 'package:tane/state/inventory_cubit.dart';
import '../support/test_support.dart';
/// The aggregate crop calendar: `watchCalendar` surfaces every variety with a
/// recorded phase, and the inventory carries sow-months for the "this month"
/// filter.
void main() {
late AppDatabase db;
setUp(() => db = newTestDatabase());
tearDown(() => db.close());
test('watchCalendar emits varieties with a recorded phase, masks intact',
() async {
final repo = newTestRepository(db);
final tomato = await repo.addQuickVariety(label: 'Tomate rosa');
await repo.updateVariety(
id: tomato,
sowMonths: Value(monthsToMask([2, 3])), // Feb, Mar
seedHarvestMonths: Value(monthsToMask([8])), // Aug
);
// A variety with no calendar at all is excluded.
await repo.addQuickVariety(label: 'Sin calendario');
final entries = await repo.watchCalendar().first;
expect(entries, hasLength(1));
final e = entries.single;
expect(e.label, 'Tomate rosa');
expect(maskHasMonth(e.sowMonths, 3), isTrue);
expect(maskHasMonth(e.sowMonths, 5), isFalse);
expect(maskHasMonth(e.seedHarvestMonths, 8), isTrue);
});
test('the inventory list carries sow-months', () async {
final repo = newTestRepository(db);
final id = await repo.addQuickVariety(label: 'Lechuga');
await repo.updateVariety(id: id, sowMonths: Value(monthsToMask([9])));
final items = await repo.watchInventory().first;
expect(items.single.sowMonths, monthsToMask([9]));
});
test('the sow-this-month filter keeps only what sows in the pinned month', () {
const march = 3;
final sowsInMarch = _item('a', monthsToMask([3, 4]));
final sowsInJune = _item('b', monthsToMask([6]));
final noCalendar = _item('c', null);
final state = InventoryState(
items: [sowsInMarch, sowsInJune, noCalendar],
sowThisMonthOnly: true,
filterMonth: march,
loading: false,
);
expect(state.visibleItems.map((i) => i.id), ['a']);
expect(state.hasSowCalendar, isTrue);
});
}
VarietyListItem _item(String id, int? sowMonths) =>
VarietyListItem(id: id, label: id, sowMonths: sowMonths);

View file

@ -0,0 +1,58 @@
import 'package:drift/drift.dart' show Value;
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:tane/domain/crop_calendar.dart';
import 'package:tane/i18n/strings.g.dart';
import 'package:tane/ui/calendar_screen.dart';
import '../support/test_support.dart';
/// The "what's due this month" screen groups varieties by phase for the picked
/// month, and switching months changes what's shown.
void main() {
testWidgets('shows a variety under Sow for its recorded month, hidden in others',
(tester) async {
final db = newTestDatabase();
addTearDown(db.close);
final repo = newTestRepository(db);
final id = await repo.addQuickVariety(label: 'Rúcula');
await repo.updateVariety(id: id, sowMonths: Value(monthsToMask([3])));
await tester.pumpWidget(wrapScreen(
repository: repo,
locale: AppLocale.es,
child: const CalendarScreen(initialMonth: 3),
));
await tester.pumpAndSettle();
expect(find.byKey(Key('calendar.entry.$id')), findsOneWidget);
expect(find.text('Rúcula'), findsOneWidget);
// Jump to a month it doesn't sow in → empty state, entry gone.
await tester.tap(find.byKey(const Key('calendar.month.6')));
await tester.pumpAndSettle();
expect(find.byKey(Key('calendar.entry.$id')), findsNothing);
await disposeTree(tester);
});
testWidgets('an empty month shows the "nothing" message', (tester) async {
final db = newTestDatabase();
addTearDown(db.close);
final repo = newTestRepository(db);
final id = await repo.addQuickVariety(label: 'Tomate');
await repo.updateVariety(id: id, sowMonths: Value(monthsToMask([4])));
await tester.pumpWidget(wrapScreen(
repository: repo,
locale: AppLocale.es,
child: const CalendarScreen(initialMonth: 1), // nothing in January
));
await tester.pumpAndSettle();
expect(find.byKey(Key('calendar.entry.$id')), findsNothing);
expect(find.byIcon(Icons.event_available), findsOneWidget);
await disposeTree(tester);
});
}

View file

@ -9,7 +9,10 @@ import 'package:tane/services/social_account_store.dart';
import 'package:tane/services/social_connection.dart';
import 'package:tane/services/social_service.dart';
import 'package:tane/services/social_settings.dart';
import 'package:drift/drift.dart' show Value;
import 'package:tane/domain/crop_calendar.dart';
import 'package:tane/ui/about_screen.dart';
import 'package:tane/ui/calendar_screen.dart';
import 'package:tane/ui/chat_screen.dart';
import 'package:tane/ui/home_screen.dart';
import 'package:tane/ui/intro_screen.dart';
@ -294,6 +297,24 @@ void main() {
await disposeTree(tester);
});
testWidgets('the "this month" calendar fits a small screen', (tester) async {
final db = newTestDatabase();
addTearDown(db.close);
final repo = newTestRepository(db);
final id = await repo.addQuickVariety(
label: 'Lechuga maravilla de verano', category: 'Asteráceas');
await repo.updateVariety(id: id, sowMonths: Value(monthsToMask([3])));
await pumpSmall(
tester,
wrapScreen(
repository: repo,
locale: AppLocale.es,
child: const CalendarScreen(initialMonth: 3),
),
);
await disposeTree(tester);
});
// NOTE: the variety-detail screen is deliberately NOT overflow-checked here.
// It reports a ~2px overflow on a RenderFlex that is already DISPOSED/DEFUNCT
// a transient stale frame while its cubit's Drift stream rebuilds during