diff --git a/apps/app_seeds/lib/app.dart b/apps/app_seeds/lib/app.dart index 28a0a37..4d58c91 100644 --- a/apps/app_seeds/lib/app.dart +++ b/apps/app_seeds/lib/app.dart @@ -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( diff --git a/apps/app_seeds/lib/data/variety_repository.dart b/apps/app_seeds/lib/data/variety_repository.dart index 8989d1f..104581e 100644 --- a/apps/app_seeds/lib/data/variety_repository.dart +++ b/apps/app_seeds/lib/data/variety_repository.dart @@ -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 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 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> watchCalendar() { + final triggers = StreamGroup.merge([ + (_db.select( + _db.varieties, + )..where((v) => v.isDeleted.equals(false))).watch().map((_) {}), + _db.select(_db.attachments).watch().map((_) {}), + ]); + return triggers.asyncMap((_) => _loadCalendar()); + } + + Future> _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> _speciesViabilityFor(Set speciesIds) async { diff --git a/apps/app_seeds/lib/i18n/ast.i18n.json b/apps/app_seeds/lib/i18n/ast.i18n.json index 63a4b6c..c30bd62 100644 --- a/apps/app_seeds/lib/i18n/ast.i18n.json +++ b/apps/app_seeds/lib/i18n/ast.i18n.json @@ -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": { diff --git a/apps/app_seeds/lib/i18n/en.i18n.json b/apps/app_seeds/lib/i18n/en.i18n.json index 76e1ea9..edd46df 100644 --- a/apps/app_seeds/lib/i18n/en.i18n.json +++ b/apps/app_seeds/lib/i18n/en.i18n.json @@ -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": { diff --git a/apps/app_seeds/lib/i18n/es.i18n.json b/apps/app_seeds/lib/i18n/es.i18n.json index 32b8ee2..d933b16 100644 --- a/apps/app_seeds/lib/i18n/es.i18n.json +++ b/apps/app_seeds/lib/i18n/es.i18n.json @@ -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": { diff --git a/apps/app_seeds/lib/i18n/pt.i18n.json b/apps/app_seeds/lib/i18n/pt.i18n.json index 827311f..8cdd94b 100644 --- a/apps/app_seeds/lib/i18n/pt.i18n.json +++ b/apps/app_seeds/lib/i18n/pt.i18n.json @@ -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": { diff --git a/apps/app_seeds/lib/i18n/strings.g.dart b/apps/app_seeds/lib/i18n/strings.g.dart index ff201f4..7c0e805 100644 --- a/apps/app_seeds/lib/i18n/strings.g.dart +++ b/apps/app_seeds/lib/i18n/strings.g.dart @@ -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 diff --git a/apps/app_seeds/lib/i18n/strings_ast.g.dart b/apps/app_seeds/lib/i18n/strings_ast.g.dart index 98355bc..98cd34b 100644 --- a/apps/app_seeds/lib/i18n/strings_ast.g.dart +++ b/apps/app_seeds/lib/i18n/strings_ast.g.dart @@ -39,6 +39,7 @@ class TranslationsAst extends Translations with BaseTranslations? 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 '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', diff --git a/apps/app_seeds/lib/i18n/strings_en.g.dart b/apps/app_seeds/lib/i18n/strings_en.g.dart index 6256889..c9547bd 100644 --- a/apps/app_seeds/lib/i18n/strings_en.g.dart +++ b/apps/app_seeds/lib/i18n/strings_en.g.dart @@ -40,6 +40,7 @@ class Translations with BaseTranslations { Translations $copyWith({TranslationMetadata? 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 { 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', diff --git a/apps/app_seeds/lib/i18n/strings_es.g.dart b/apps/app_seeds/lib/i18n/strings_es.g.dart index f9fb9ab..97343d0 100644 --- a/apps/app_seeds/lib/i18n/strings_es.g.dart +++ b/apps/app_seeds/lib/i18n/strings_es.g.dart @@ -39,6 +39,7 @@ class TranslationsEs extends Translations with BaseTranslations? 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 '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', diff --git a/apps/app_seeds/lib/i18n/strings_pt.g.dart b/apps/app_seeds/lib/i18n/strings_pt.g.dart index 5bbb396..188ea2c 100644 --- a/apps/app_seeds/lib/i18n/strings_pt.g.dart +++ b/apps/app_seeds/lib/i18n/strings_pt.g.dart @@ -39,6 +39,7 @@ class TranslationsPt extends Translations with BaseTranslations? 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 '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', diff --git a/apps/app_seeds/lib/state/inventory_cubit.dart b/apps/app_seeds/lib/state/inventory_cubit.dart index 8d55ba5..210af20 100644 --- a/apps/app_seeds/lib/state/inventory_cubit.dart +++ b/apps/app_seeds/lib/state/inventory_cubit.dart @@ -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? 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 { - 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 { } final VarietyRepository _repo; + final int Function() _nowMonth; late final StreamSubscription< ({List items, List drafts}) > @@ -192,6 +220,12 @@ class InventoryCubit extends Cubit { 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 { organicOnly: false, needsReproductionOnly: false, sharingOnly: false, + sowThisMonthOnly: false, ), ); diff --git a/apps/app_seeds/lib/ui/app_drawer.dart b/apps/app_seeds/lib/ui/app_drawer.dart index 9114992..98d4cb1 100644 --- a/apps/app_seeds/lib/ui/app_drawer.dart +++ b/apps/app_seeds/lib/ui/app_drawer.dart @@ -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, diff --git a/apps/app_seeds/lib/ui/calendar_screen.dart b/apps/app_seeds/lib/ui/calendar_screen.dart new file mode 100644 index 0000000..458f8f5 --- /dev/null +++ b/apps/app_seeds/lib/ui/calendar_screen.dart @@ -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 createState() => _CalendarScreenState(); +} + +class _CalendarScreenState extends State { + late int _month = widget.initialMonth ?? DateTime.now().month; + + @override + Widget build(BuildContext context) { + final t = context.t; + final repo = context.read(); + 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>( + stream: repo.watchCalendar(), + builder: (context, snapshot) { + final entries = snapshot.data ?? const []; + final sections = []; + 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 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 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); diff --git a/apps/app_seeds/lib/ui/home_screen.dart b/apps/app_seeds/lib/ui/home_screen.dart index 93c7b81..42b08c4 100644 --- a/apps/app_seeds/lib/ui/home_screen.dart +++ b/apps/app_seeds/lib/ui/home_screen.dart @@ -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, diff --git a/apps/app_seeds/lib/ui/inventory_list_screen.dart b/apps/app_seeds/lib/ui/inventory_list_screen.dart index 70de27b..4a49461 100644 --- a/apps/app_seeds/lib/ui/inventory_list_screen.dart +++ b/apps/app_seeds/lib/ui/inventory_list_screen.dart @@ -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 = [ + 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. diff --git a/apps/app_seeds/test/data/calendar_test.dart b/apps/app_seeds/test/data/calendar_test.dart new file mode 100644 index 0000000..4d81b8a --- /dev/null +++ b/apps/app_seeds/test/data/calendar_test.dart @@ -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); diff --git a/apps/app_seeds/test/ui/calendar_screen_test.dart b/apps/app_seeds/test/ui/calendar_screen_test.dart new file mode 100644 index 0000000..95d669e --- /dev/null +++ b/apps/app_seeds/test/ui/calendar_screen_test.dart @@ -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); + }); +} diff --git a/apps/app_seeds/test/ui/small_screen_overflow_test.dart b/apps/app_seeds/test/ui/small_screen_overflow_test.dart index f3b0dd6..ec65997 100644 --- a/apps/app_seeds/test/ui/small_screen_overflow_test.dart +++ b/apps/app_seeds/test/ui/small_screen_overflow_test.dart @@ -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