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:
parent
28e8318026
commit
171daabce3
19 changed files with 681 additions and 5 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
247
apps/app_seeds/lib/ui/calendar_screen.dart
Normal file
247
apps/app_seeds/lib/ui/calendar_screen.dart
Normal 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);
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue