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
5b487ba1ec
commit
44337497d0
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 'state/variety_detail_cubit.dart';
|
||||||
import 'ui/about_screen.dart';
|
import 'ui/about_screen.dart';
|
||||||
import 'ui/auto_backup_gate.dart';
|
import 'ui/auto_backup_gate.dart';
|
||||||
|
import 'ui/calendar_screen.dart';
|
||||||
import 'ui/chat_list_screen.dart';
|
import 'ui/chat_list_screen.dart';
|
||||||
import 'ui/chat_screen.dart';
|
import 'ui/chat_screen.dart';
|
||||||
import 'ui/home_screen.dart';
|
import 'ui/home_screen.dart';
|
||||||
|
|
@ -266,6 +267,12 @@ class TaneApp extends StatelessWidget {
|
||||||
path: '/sales',
|
path: '/sales',
|
||||||
builder: (context, state) => const SalesScreen(),
|
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(
|
GoRoute(
|
||||||
path: '/variety/:id',
|
path: '/variety/:id',
|
||||||
builder: (context, state) => BlocProvider(
|
builder: (context, state) => BlocProvider(
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ class VarietyListItem extends Equatable {
|
||||||
this.needsReproduction = false,
|
this.needsReproduction = false,
|
||||||
this.isShared = false,
|
this.isShared = false,
|
||||||
this.viability = SeedViability.unknown,
|
this.viability = SeedViability.unknown,
|
||||||
|
this.sowMonths,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String id;
|
final String id;
|
||||||
|
|
@ -62,6 +63,10 @@ class VarietyListItem extends Equatable {
|
||||||
/// or there is no reference figure.
|
/// or there is no reference figure.
|
||||||
final SeedViability viability;
|
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
|
@override
|
||||||
List<Object?> get props => [
|
List<Object?> get props => [
|
||||||
id,
|
id,
|
||||||
|
|
@ -75,6 +80,47 @@ class VarietyListItem extends Equatable {
|
||||||
needsReproduction,
|
needsReproduction,
|
||||||
isShared,
|
isShared,
|
||||||
viability,
|
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],
|
: speciesViability[v.speciesId],
|
||||||
currentYear: currentYear,
|
currentYear: currentYear,
|
||||||
),
|
),
|
||||||
|
sowMonths: v.sowMonths,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.toList();
|
.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
|
/// Maps each of [speciesIds] to its bundled viability figure (years); species
|
||||||
/// without a figure are simply absent from the map.
|
/// without a figure are simply absent from the map.
|
||||||
Future<Map<String, int>> _speciesViabilityFor(Set<String> speciesIds) async {
|
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": {
|
"app": {
|
||||||
"title": "Tanemaki"
|
"title": "Tanemaki"
|
||||||
},
|
},
|
||||||
|
|
@ -38,6 +44,7 @@
|
||||||
"following": "Siguiendo",
|
"following": "Siguiendo",
|
||||||
"plantares": "Plantares",
|
"plantares": "Plantares",
|
||||||
"sales": "Ventes",
|
"sales": "Ventes",
|
||||||
|
"calendar": "Calendariu",
|
||||||
"settings": "Axustes"
|
"settings": "Axustes"
|
||||||
},
|
},
|
||||||
"settings": {
|
"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": {
|
"app": {
|
||||||
"title": "Tanemaki"
|
"title": "Tanemaki"
|
||||||
},
|
},
|
||||||
|
|
@ -39,6 +45,7 @@
|
||||||
"following": "Following",
|
"following": "Following",
|
||||||
"plantares": "Plantares",
|
"plantares": "Plantares",
|
||||||
"sales": "Sales",
|
"sales": "Sales",
|
||||||
|
"calendar": "Calendar",
|
||||||
"settings": "Settings"
|
"settings": "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": {
|
"app": {
|
||||||
"title": "Tanemaki"
|
"title": "Tanemaki"
|
||||||
},
|
},
|
||||||
|
|
@ -39,6 +45,7 @@
|
||||||
"following": "Siguiendo",
|
"following": "Siguiendo",
|
||||||
"plantares": "Plantares",
|
"plantares": "Plantares",
|
||||||
"sales": "Ventas",
|
"sales": "Ventas",
|
||||||
|
"calendar": "Calendario",
|
||||||
"settings": "Ajustes"
|
"settings": "Ajustes"
|
||||||
},
|
},
|
||||||
"settings": {
|
"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": {
|
"app": {
|
||||||
"title": "Tanemaki"
|
"title": "Tanemaki"
|
||||||
},
|
},
|
||||||
|
|
@ -39,6 +45,7 @@
|
||||||
"following": "A seguir",
|
"following": "A seguir",
|
||||||
"plantares": "Plantares",
|
"plantares": "Plantares",
|
||||||
"sales": "Vendas",
|
"sales": "Vendas",
|
||||||
|
"calendar": "Calendário",
|
||||||
"settings": "Definições"
|
"settings": "Definições"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,9 @@
|
||||||
/// To regenerate, run: `dart run slang`
|
/// To regenerate, run: `dart run slang`
|
||||||
///
|
///
|
||||||
/// Locales: 4
|
/// 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
|
// coverage:ignore-file
|
||||||
// ignore_for_file: type=lint, unused_import
|
// 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);
|
TranslationsAst $copyWith({TranslationMetadata<AppLocale, Translations>? meta}) => TranslationsAst(meta: meta ?? this.$meta);
|
||||||
|
|
||||||
// Translations
|
// 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$app$ast app = _Translations$app$ast._(_root);
|
||||||
@override late final _Translations$bootstrap$ast bootstrap = _Translations$bootstrap$ast._(_root);
|
@override late final _Translations$bootstrap$ast bootstrap = _Translations$bootstrap$ast._(_root);
|
||||||
@override late final _Translations$common$ast common = _Translations$common$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);
|
@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
|
// Path: app
|
||||||
class _Translations$app$ast extends Translations$app$en {
|
class _Translations$app$ast extends Translations$app$en {
|
||||||
_Translations$app$ast._(TranslationsAst root) : this._root = root, super.internal(root);
|
_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 following => 'Siguiendo';
|
||||||
@override String get plantares => 'Plantares';
|
@override String get plantares => 'Plantares';
|
||||||
@override String get sales => 'Ventes';
|
@override String get sales => 'Ventes';
|
||||||
|
@override String get calendar => 'Calendariu';
|
||||||
@override String get settings => 'Axustes';
|
@override String get settings => 'Axustes';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1208,6 +1223,10 @@ class _Translations$intro$slides$plantare$ast extends Translations$intro$slides$
|
||||||
extension on TranslationsAst {
|
extension on TranslationsAst {
|
||||||
dynamic _flatMapFunction(String path) {
|
dynamic _flatMapFunction(String path) {
|
||||||
return switch (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',
|
'app.title' => 'Tanemaki',
|
||||||
'bootstrap.failed' => 'Tanemaki nun pudo aniciar',
|
'bootstrap.failed' => 'Tanemaki nun pudo aniciar',
|
||||||
'bootstrap.retry' => 'Reintentar',
|
'bootstrap.retry' => 'Reintentar',
|
||||||
|
|
@ -1236,6 +1255,7 @@ extension on TranslationsAst {
|
||||||
'menu.following' => 'Siguiendo',
|
'menu.following' => 'Siguiendo',
|
||||||
'menu.plantares' => 'Plantares',
|
'menu.plantares' => 'Plantares',
|
||||||
'menu.sales' => 'Ventes',
|
'menu.sales' => 'Ventes',
|
||||||
|
'menu.calendar' => 'Calendariu',
|
||||||
'menu.settings' => 'Axustes',
|
'menu.settings' => 'Axustes',
|
||||||
'settings.language' => 'Llingua',
|
'settings.language' => 'Llingua',
|
||||||
'settings.systemLanguage' => 'Llingua del sistema',
|
'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 $copyWith({TranslationMetadata<AppLocale, Translations>? meta}) => Translations(meta: meta ?? this.$meta);
|
||||||
|
|
||||||
// Translations
|
// 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$app$en app = Translations$app$en.internal(_root);
|
||||||
late final Translations$bootstrap$en bootstrap = Translations$bootstrap$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);
|
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);
|
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
|
// Path: app
|
||||||
class Translations$app$en {
|
class Translations$app$en {
|
||||||
Translations$app$en.internal(this._root);
|
Translations$app$en.internal(this._root);
|
||||||
|
|
@ -222,6 +244,9 @@ class Translations$menu$en {
|
||||||
/// en: 'Sales'
|
/// en: 'Sales'
|
||||||
String get sales => 'Sales';
|
String get sales => 'Sales';
|
||||||
|
|
||||||
|
/// en: 'Calendar'
|
||||||
|
String get calendar => 'Calendar';
|
||||||
|
|
||||||
/// en: 'Settings'
|
/// en: 'Settings'
|
||||||
String get settings => 'Settings';
|
String get settings => 'Settings';
|
||||||
}
|
}
|
||||||
|
|
@ -2111,6 +2136,10 @@ class Translations$intro$slides$plantare$en {
|
||||||
extension on Translations {
|
extension on Translations {
|
||||||
dynamic _flatMapFunction(String path) {
|
dynamic _flatMapFunction(String path) {
|
||||||
return switch (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',
|
'app.title' => 'Tanemaki',
|
||||||
'bootstrap.failed' => 'Tanemaki couldn\'t start',
|
'bootstrap.failed' => 'Tanemaki couldn\'t start',
|
||||||
'bootstrap.retry' => 'Try again',
|
'bootstrap.retry' => 'Try again',
|
||||||
|
|
@ -2140,6 +2169,7 @@ extension on Translations {
|
||||||
'menu.following' => 'Following',
|
'menu.following' => 'Following',
|
||||||
'menu.plantares' => 'Plantares',
|
'menu.plantares' => 'Plantares',
|
||||||
'menu.sales' => 'Sales',
|
'menu.sales' => 'Sales',
|
||||||
|
'menu.calendar' => 'Calendar',
|
||||||
'menu.settings' => 'Settings',
|
'menu.settings' => 'Settings',
|
||||||
'settings.language' => 'Language',
|
'settings.language' => 'Language',
|
||||||
'settings.systemLanguage' => 'System 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);
|
TranslationsEs $copyWith({TranslationMetadata<AppLocale, Translations>? meta}) => TranslationsEs(meta: meta ?? this.$meta);
|
||||||
|
|
||||||
// Translations
|
// 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$app$es app = _Translations$app$es._(_root);
|
||||||
@override late final _Translations$bootstrap$es bootstrap = _Translations$bootstrap$es._(_root);
|
@override late final _Translations$bootstrap$es bootstrap = _Translations$bootstrap$es._(_root);
|
||||||
@override late final _Translations$common$es common = _Translations$common$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);
|
@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
|
// Path: app
|
||||||
class _Translations$app$es extends Translations$app$en {
|
class _Translations$app$es extends Translations$app$en {
|
||||||
_Translations$app$es._(TranslationsEs root) : this._root = root, super.internal(root);
|
_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 following => 'Siguiendo';
|
||||||
@override String get plantares => 'Plantares';
|
@override String get plantares => 'Plantares';
|
||||||
@override String get sales => 'Ventas';
|
@override String get sales => 'Ventas';
|
||||||
|
@override String get calendar => 'Calendario';
|
||||||
@override String get settings => 'Ajustes';
|
@override String get settings => 'Ajustes';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1210,6 +1225,10 @@ class _Translations$intro$slides$plantare$es extends Translations$intro$slides$p
|
||||||
extension on TranslationsEs {
|
extension on TranslationsEs {
|
||||||
dynamic _flatMapFunction(String path) {
|
dynamic _flatMapFunction(String path) {
|
||||||
return switch (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',
|
'app.title' => 'Tanemaki',
|
||||||
'bootstrap.failed' => 'Tanemaki no pudo arrancar',
|
'bootstrap.failed' => 'Tanemaki no pudo arrancar',
|
||||||
'bootstrap.retry' => 'Reintentar',
|
'bootstrap.retry' => 'Reintentar',
|
||||||
|
|
@ -1239,6 +1258,7 @@ extension on TranslationsEs {
|
||||||
'menu.following' => 'Siguiendo',
|
'menu.following' => 'Siguiendo',
|
||||||
'menu.plantares' => 'Plantares',
|
'menu.plantares' => 'Plantares',
|
||||||
'menu.sales' => 'Ventas',
|
'menu.sales' => 'Ventas',
|
||||||
|
'menu.calendar' => 'Calendario',
|
||||||
'menu.settings' => 'Ajustes',
|
'menu.settings' => 'Ajustes',
|
||||||
'settings.language' => 'Idioma',
|
'settings.language' => 'Idioma',
|
||||||
'settings.systemLanguage' => 'Idioma del sistema',
|
'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);
|
TranslationsPt $copyWith({TranslationMetadata<AppLocale, Translations>? meta}) => TranslationsPt(meta: meta ?? this.$meta);
|
||||||
|
|
||||||
// Translations
|
// 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$app$pt app = _Translations$app$pt._(_root);
|
||||||
@override late final _Translations$bootstrap$pt bootstrap = _Translations$bootstrap$pt._(_root);
|
@override late final _Translations$bootstrap$pt bootstrap = _Translations$bootstrap$pt._(_root);
|
||||||
@override late final _Translations$common$pt common = _Translations$common$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);
|
@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
|
// Path: app
|
||||||
class _Translations$app$pt extends Translations$app$en {
|
class _Translations$app$pt extends Translations$app$en {
|
||||||
_Translations$app$pt._(TranslationsPt root) : this._root = root, super.internal(root);
|
_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 following => 'A seguir';
|
||||||
@override String get plantares => 'Plantares';
|
@override String get plantares => 'Plantares';
|
||||||
@override String get sales => 'Vendas';
|
@override String get sales => 'Vendas';
|
||||||
|
@override String get calendar => 'Calendário';
|
||||||
@override String get settings => 'Definições';
|
@override String get settings => 'Definições';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1207,6 +1222,10 @@ class _Translations$intro$slides$plantare$pt extends Translations$intro$slides$p
|
||||||
extension on TranslationsPt {
|
extension on TranslationsPt {
|
||||||
dynamic _flatMapFunction(String path) {
|
dynamic _flatMapFunction(String path) {
|
||||||
return switch (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',
|
'app.title' => 'Tanemaki',
|
||||||
'bootstrap.failed' => 'O Tanemaki não conseguiu iniciar',
|
'bootstrap.failed' => 'O Tanemaki não conseguiu iniciar',
|
||||||
'bootstrap.retry' => 'Tentar de novo',
|
'bootstrap.retry' => 'Tentar de novo',
|
||||||
|
|
@ -1236,6 +1255,7 @@ extension on TranslationsPt {
|
||||||
'menu.following' => 'A seguir',
|
'menu.following' => 'A seguir',
|
||||||
'menu.plantares' => 'Plantares',
|
'menu.plantares' => 'Plantares',
|
||||||
'menu.sales' => 'Vendas',
|
'menu.sales' => 'Vendas',
|
||||||
|
'menu.calendar' => 'Calendário',
|
||||||
'menu.settings' => 'Definições',
|
'menu.settings' => 'Definições',
|
||||||
'settings.language' => 'Idioma',
|
'settings.language' => 'Idioma',
|
||||||
'settings.systemLanguage' => 'Idioma do sistema',
|
'settings.systemLanguage' => 'Idioma do sistema',
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
|
||||||
import '../data/variety_repository.dart';
|
import '../data/variety_repository.dart';
|
||||||
import '../db/enums.dart';
|
import '../db/enums.dart';
|
||||||
|
import '../domain/crop_calendar.dart';
|
||||||
|
|
||||||
/// Inventory list state: all items from the DB plus the current search query
|
/// Inventory list state: all items from the DB plus the current search query
|
||||||
/// and active filters. [visibleItems] applies query ∧ category ∧ form; grouping
|
/// and active filters. [visibleItems] applies query ∧ category ∧ form; grouping
|
||||||
|
|
@ -19,6 +20,8 @@ class InventoryState extends Equatable {
|
||||||
this.organicOnly = false,
|
this.organicOnly = false,
|
||||||
this.needsReproductionOnly = false,
|
this.needsReproductionOnly = false,
|
||||||
this.sharingOnly = false,
|
this.sharingOnly = false,
|
||||||
|
this.sowThisMonthOnly = false,
|
||||||
|
this.filterMonth = 0,
|
||||||
this.loading = true,
|
this.loading = true,
|
||||||
this.selectionMode = false,
|
this.selectionMode = false,
|
||||||
this.selectedIds = const {},
|
this.selectedIds = const {},
|
||||||
|
|
@ -49,6 +52,15 @@ class InventoryState extends Equatable {
|
||||||
/// "what I share" view.
|
/// "what I share" view.
|
||||||
final bool sharingOnly;
|
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;
|
final bool loading;
|
||||||
|
|
||||||
/// Whether the list is in multi-select mode — used to pick a subset of the
|
/// 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 (organicOnly && !i.isOrganic) return false;
|
||||||
if (needsReproductionOnly && !i.needsReproduction) return false;
|
if (needsReproductionOnly && !i.needsReproduction) return false;
|
||||||
if (sharingOnly && !i.isShared) return false;
|
if (sharingOnly && !i.isShared) return false;
|
||||||
|
if (sowThisMonthOnly && !maskHasMonth(i.sowMonths, filterMonth)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}).toList();
|
}).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
|
/// Whether any item is flagged organic, so the UI only offers the eco filter
|
||||||
/// chip when it would actually match something.
|
/// chip when it would actually match something.
|
||||||
bool get hasOrganic => items.any((i) => i.isOrganic);
|
bool get hasOrganic => items.any((i) => i.isOrganic);
|
||||||
|
|
@ -109,6 +128,8 @@ class InventoryState extends Equatable {
|
||||||
bool? organicOnly,
|
bool? organicOnly,
|
||||||
bool? needsReproductionOnly,
|
bool? needsReproductionOnly,
|
||||||
bool? sharingOnly,
|
bool? sharingOnly,
|
||||||
|
bool? sowThisMonthOnly,
|
||||||
|
int? filterMonth,
|
||||||
bool? loading,
|
bool? loading,
|
||||||
bool? selectionMode,
|
bool? selectionMode,
|
||||||
Set<String>? selectedIds,
|
Set<String>? selectedIds,
|
||||||
|
|
@ -123,6 +144,8 @@ class InventoryState extends Equatable {
|
||||||
needsReproductionOnly:
|
needsReproductionOnly:
|
||||||
needsReproductionOnly ?? this.needsReproductionOnly,
|
needsReproductionOnly ?? this.needsReproductionOnly,
|
||||||
sharingOnly: sharingOnly ?? this.sharingOnly,
|
sharingOnly: sharingOnly ?? this.sharingOnly,
|
||||||
|
sowThisMonthOnly: sowThisMonthOnly ?? this.sowThisMonthOnly,
|
||||||
|
filterMonth: filterMonth ?? this.filterMonth,
|
||||||
loading: loading ?? this.loading,
|
loading: loading ?? this.loading,
|
||||||
selectionMode: selectionMode ?? this.selectionMode,
|
selectionMode: selectionMode ?? this.selectionMode,
|
||||||
selectedIds: selectedIds ?? this.selectedIds,
|
selectedIds: selectedIds ?? this.selectedIds,
|
||||||
|
|
@ -139,6 +162,8 @@ class InventoryState extends Equatable {
|
||||||
organicOnly,
|
organicOnly,
|
||||||
needsReproductionOnly,
|
needsReproductionOnly,
|
||||||
sharingOnly,
|
sharingOnly,
|
||||||
|
sowThisMonthOnly,
|
||||||
|
filterMonth,
|
||||||
loading,
|
loading,
|
||||||
selectionMode,
|
selectionMode,
|
||||||
selectedIds,
|
selectedIds,
|
||||||
|
|
@ -148,7 +173,9 @@ class InventoryState extends Equatable {
|
||||||
/// Subscribes to the repository's reactive inventory stream. The list updates
|
/// Subscribes to the repository's reactive inventory stream. The list updates
|
||||||
/// automatically after a quick-add — no manual refresh.
|
/// automatically after a quick-add — no manual refresh.
|
||||||
class InventoryCubit extends Cubit<InventoryState> {
|
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
|
// One combined subscription (list + draft tray). Two separate StreamGroups
|
||||||
// here re-emit in a loop that hangs widget tests — see watchInventoryView.
|
// here re-emit in a loop that hangs widget tests — see watchInventoryView.
|
||||||
_sub = _repo.watchInventoryView().listen(
|
_sub = _repo.watchInventoryView().listen(
|
||||||
|
|
@ -159,6 +186,7 @@ class InventoryCubit extends Cubit<InventoryState> {
|
||||||
}
|
}
|
||||||
|
|
||||||
final VarietyRepository _repo;
|
final VarietyRepository _repo;
|
||||||
|
final int Function() _nowMonth;
|
||||||
late final StreamSubscription<
|
late final StreamSubscription<
|
||||||
({List<VarietyListItem> items, List<VarietyListItem> drafts})
|
({List<VarietyListItem> items, List<VarietyListItem> drafts})
|
||||||
>
|
>
|
||||||
|
|
@ -192,6 +220,12 @@ class InventoryCubit extends Cubit<InventoryState> {
|
||||||
void toggleSharingOnly() =>
|
void toggleSharingOnly() =>
|
||||||
emit(state.copyWith(sharingOnly: !state.sharingOnly));
|
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).
|
/// Enters multi-select mode with an empty selection (from the toolbar).
|
||||||
void startSelection() =>
|
void startSelection() =>
|
||||||
emit(state.copyWith(selectionMode: true, selectedIds: const {}));
|
emit(state.copyWith(selectionMode: true, selectedIds: const {}));
|
||||||
|
|
@ -230,6 +264,7 @@ class InventoryCubit extends Cubit<InventoryState> {
|
||||||
organicOnly: false,
|
organicOnly: false,
|
||||||
needsReproductionOnly: false,
|
needsReproductionOnly: false,
|
||||||
sharingOnly: false,
|
sharingOnly: false,
|
||||||
|
sowThisMonthOnly: false,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,15 @@ class AppDrawer extends StatelessWidget {
|
||||||
context.push('/sales');
|
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(
|
_DrawerItem(
|
||||||
icon: const Icon(Symbols.storefront),
|
icon: const Icon(Symbols.storefront),
|
||||||
label: t.menu.market,
|
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'),
|
onTap: () => context.push('/inventory'),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
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(
|
_OutlinedMenuCard(
|
||||||
key: const Key('home.market'),
|
key: const Key('home.market'),
|
||||||
icon: Icons.storefront_outlined,
|
icon: Icons.storefront_outlined,
|
||||||
|
|
|
||||||
|
|
@ -286,11 +286,14 @@ class _FilterBar extends StatelessWidget {
|
||||||
final hasNeedsReproduction = state.hasNeedsReproduction;
|
final hasNeedsReproduction = state.hasNeedsReproduction;
|
||||||
// Only offer the "I share" chip when some lot is marked to share.
|
// Only offer the "I share" chip when some lot is marked to share.
|
||||||
final hasShared = state.hasShared;
|
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 &&
|
if (categories.isEmpty &&
|
||||||
forms.isEmpty &&
|
forms.isEmpty &&
|
||||||
!hasOrganic &&
|
!hasOrganic &&
|
||||||
!hasNeedsReproduction &&
|
!hasNeedsReproduction &&
|
||||||
!hasShared) {
|
!hasShared &&
|
||||||
|
!hasSowCalendar) {
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -298,6 +301,18 @@ class _FilterBar extends StatelessWidget {
|
||||||
// attributes (green icons) · forms (per-form tonality) · families (each a
|
// attributes (green icons) · forms (per-form tonality) · families (each a
|
||||||
// stable earthy tonality). Grouping + colour makes a long row scannable.
|
// stable earthy tonality). Grouping + colour makes a long row scannable.
|
||||||
final attrChips = <Widget>[
|
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)
|
if (hasShared)
|
||||||
FilterChip(
|
FilterChip(
|
||||||
key: const Key('inventory.filter.sharing'),
|
key: const Key('inventory.filter.sharing'),
|
||||||
|
|
@ -361,7 +376,8 @@ class _FilterBar extends StatelessWidget {
|
||||||
state.typeFilter.isNotEmpty ||
|
state.typeFilter.isNotEmpty ||
|
||||||
state.organicOnly ||
|
state.organicOnly ||
|
||||||
state.needsReproductionOnly ||
|
state.needsReproductionOnly ||
|
||||||
state.sharingOnly;
|
state.sharingOnly ||
|
||||||
|
state.sowThisMonthOnly;
|
||||||
|
|
||||||
// Lay out the groups in order, dropping a hairline divider between any two
|
// Lay out the groups in order, dropping a hairline divider between any two
|
||||||
// that both have chips.
|
// that both have chips.
|
||||||
|
|
|
||||||
65
apps/app_seeds/test/data/calendar_test.dart
Normal file
65
apps/app_seeds/test/data/calendar_test.dart
Normal 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);
|
||||||
58
apps/app_seeds/test/ui/calendar_screen_test.dart
Normal file
58
apps/app_seeds/test/ui/calendar_screen_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -9,7 +9,10 @@ import 'package:tane/services/social_account_store.dart';
|
||||||
import 'package:tane/services/social_connection.dart';
|
import 'package:tane/services/social_connection.dart';
|
||||||
import 'package:tane/services/social_service.dart';
|
import 'package:tane/services/social_service.dart';
|
||||||
import 'package:tane/services/social_settings.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/about_screen.dart';
|
||||||
|
import 'package:tane/ui/calendar_screen.dart';
|
||||||
import 'package:tane/ui/chat_screen.dart';
|
import 'package:tane/ui/chat_screen.dart';
|
||||||
import 'package:tane/ui/home_screen.dart';
|
import 'package:tane/ui/home_screen.dart';
|
||||||
import 'package:tane/ui/intro_screen.dart';
|
import 'package:tane/ui/intro_screen.dart';
|
||||||
|
|
@ -294,6 +297,24 @@ void main() {
|
||||||
await disposeTree(tester);
|
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.
|
// NOTE: the variety-detail screen is deliberately NOT overflow-checked here.
|
||||||
// It reports a ~2px overflow on a RenderFlex that is already DISPOSED/DEFUNCT
|
// 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
|
// — a transient stale frame while its cubit's Drift stream rebuilds during
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue