feat(inventory): month/year harvest date picker (schema v3)

Replace the free-text harvest year with a themed month/year picker:
a tappable field opens a green-styled dialog with month and year grids
(navigable, paginated years), month optional. Lots now read e.g.
"September 2021" or just "Year 2021".

- schema v3: add nullable Lot.harvest_month (1..12) + migration + tests
- repo/cubit propagate harvestMonth; localized month names (en/es) via slang
This commit is contained in:
vjrj 2026-07-08 12:09:38 +02:00
parent 9e5c82184b
commit 5d4053157f
17 changed files with 3799 additions and 57 deletions

View file

@ -0,0 +1,396 @@
import 'package:flutter/material.dart';
import '../i18n/strings.g.dart';
import 'theme.dart';
/// How many years back the picker offers. A freshly harvested lot is the common
/// case, but old seed still needs a home.
const _yearsBack = 40;
/// A harvest date: a required [year] plus an optional [month] (1..12).
class HarvestDate {
const HarvestDate(this.year, [this.month]);
final int year;
final int? month;
}
/// Formats a harvest date for display: "September 2021" when a month is set,
/// otherwise just the localized "Year 2021".
String harvestDateLabel(Translations t, {required int year, int? month}) {
if (month == null) return t.detail.year(year: year);
return '${t.harvest.monthNames[month - 1]} $year';
}
/// An inline, tappable field showing the current harvest date. Tapping opens a
/// themed month/year dialog (see [showHarvestDatePicker]). Month is optional, so
/// the field can read "September 2021" or just "2021".
class HarvestDateField extends StatelessWidget {
const HarvestDateField({
required this.year,
required this.onChanged,
this.month,
super.key,
});
final int year;
final int? month;
final void Function(int year, int? month) onChanged;
@override
Widget build(BuildContext context) {
final t = context.t;
return OutlinedButton.icon(
key: const Key('addLot.year'),
onPressed: () async {
final picked = await showHarvestDatePicker(
context,
initial: HarvestDate(year, month),
);
if (picked != null) onChanged(picked.year, picked.month);
},
icon: const Icon(Icons.event_outlined, size: 20),
label: Align(
alignment: Alignment.centerLeft,
child: Text(harvestDateLabel(t, year: year, month: month)),
),
style: OutlinedButton.styleFrom(
foregroundColor: seedGreenDark,
alignment: Alignment.centerLeft,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
minimumSize: const Size.fromHeight(0),
),
);
}
}
/// Opens the themed month/year dialog. Returns the chosen [HarvestDate], or null
/// if the user cancels.
Future<HarvestDate?> showHarvestDatePicker(
BuildContext context, {
required HarvestDate initial,
}) {
return showDialog<HarvestDate>(
context: context,
builder: (_) => _HarvestPickerDialog(initial: initial),
);
}
enum _Mode { months, years }
class _HarvestPickerDialog extends StatefulWidget {
const _HarvestPickerDialog({required this.initial});
final HarvestDate initial;
@override
State<_HarvestPickerDialog> createState() => _HarvestPickerDialogState();
}
class _HarvestPickerDialogState extends State<_HarvestPickerDialog> {
late int _year = widget.initial.year;
late int? _month = widget.initial.month;
var _mode = _Mode.months;
int get _maxYear => DateTime.now().year;
int get _minYear => _maxYear - _yearsBack;
void _setYear(int year) => setState(() {
_year = year.clamp(_minYear, _maxYear);
_mode = _Mode.months;
});
void _toggleMonth(int month) =>
setState(() => _month = _month == month ? null : month);
@override
Widget build(BuildContext context) {
final t = context.t;
return Dialog(
clipBehavior: Clip.antiAlias,
child: SizedBox(
width: 360,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_Header(
title: t.harvest.pickTitle,
selection: harvestDateLabel(t, year: _year, month: _month),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
child: _NavRow(
year: _year,
mode: _mode,
onToggleMode: () => setState(
() =>
_mode = _mode == _Mode.years ? _Mode.months : _Mode.years,
),
onStep: (delta) {
if (_mode == _Mode.months) {
_setYear(_year + delta);
} else {
// Page the year grid by a screenful (12 years).
_setYearGridPage(delta);
}
},
),
),
AnimatedSize(
duration: const Duration(milliseconds: 150),
child: _mode == _Mode.months
? _MonthGrid(
selected: _month,
onSelect: _toggleMonth,
onClear: () => setState(() => _month = null),
)
: _YearGrid(
years: _yearPage,
selected: _year,
onSelect: _setYear,
),
),
Padding(
padding: const EdgeInsets.fromLTRB(8, 4, 8, 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
key: const Key('harvestPicker.cancel'),
onPressed: () => Navigator.of(context).pop(),
child: Text(t.common.cancel),
),
TextButton(
key: const Key('harvestPicker.ok'),
onPressed: () =>
Navigator.of(context).pop(HarvestDate(_year, _month)),
child: Text(t.common.save),
),
],
),
),
],
),
),
);
}
// --- Year grid paging -----------------------------------------------------
// Top-left year of the visible page in years mode. Anchored to the current
// year so recent harvests show first; older years are one page away.
late int _yearPageAnchor = _maxYear;
List<int> get _yearPage {
// A page of up to 12 years, newest at the top-left, clamped to the range.
final start = _yearPageAnchor;
return [for (var y = start; y > start - 12 && y >= _minYear; y--) y];
}
void _setYearGridPage(int delta) => setState(() {
// delta -1 () older page, +1 () newer page.
_yearPageAnchor = (_yearPageAnchor + delta * 12).clamp(
_minYear + 11,
_maxYear,
);
});
}
class _Header extends StatelessWidget {
const _Header({required this.title, required this.selection});
final String title;
final String selection;
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
color: seedGreen,
padding: const EdgeInsets.fromLTRB(20, 16, 20, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title.toUpperCase(),
style: const TextStyle(
color: Colors.white,
fontSize: 12,
letterSpacing: 1.2,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
Text(
selection,
style: const TextStyle(
color: Colors.white,
fontSize: 30,
fontWeight: FontWeight.w400,
),
),
],
),
);
}
}
class _NavRow extends StatelessWidget {
const _NavRow({
required this.year,
required this.mode,
required this.onToggleMode,
required this.onStep,
});
final int year;
final _Mode mode;
final VoidCallback onToggleMode;
final ValueChanged<int> onStep;
@override
Widget build(BuildContext context) {
return Row(
children: [
TextButton.icon(
key: const Key('harvestPicker.yearToggle'),
onPressed: onToggleMode,
label: Text('$year'),
icon: Icon(
mode == _Mode.years ? Icons.arrow_drop_up : Icons.arrow_drop_down,
),
iconAlignment: IconAlignment.end,
style: TextButton.styleFrom(foregroundColor: seedGreenDark),
),
const Spacer(),
IconButton(
key: const Key('harvestPicker.prev'),
onPressed: () => onStep(-1),
icon: const Icon(Icons.chevron_left),
),
IconButton(
key: const Key('harvestPicker.next'),
onPressed: () => onStep(1),
icon: const Icon(Icons.chevron_right),
),
],
);
}
}
class _MonthGrid extends StatelessWidget {
const _MonthGrid({
required this.selected,
required this.onSelect,
required this.onClear,
});
final int? selected;
final ValueChanged<int> onSelect;
final VoidCallback onClear;
@override
Widget build(BuildContext context) {
final t = context.t;
final names = t.harvest.monthNames;
return Column(
children: [
GridView.count(
crossAxisCount: 3,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
padding: const EdgeInsets.symmetric(horizontal: 12),
childAspectRatio: 2.4,
children: [
for (var m = 1; m <= 12; m++)
_PickCell(
key: Key('harvestPicker.month.$m'),
label: names[m - 1],
selected: selected == m,
onTap: () => onSelect(m),
),
],
),
TextButton(
key: const Key('harvestPicker.anyMonth'),
onPressed: selected == null ? null : onClear,
child: Text(t.harvest.anyMonth),
),
],
);
}
}
class _YearGrid extends StatelessWidget {
const _YearGrid({
required this.years,
required this.selected,
required this.onSelect,
});
final List<int> years;
final int selected;
final ValueChanged<int> onSelect;
@override
Widget build(BuildContext context) {
return GridView.count(
crossAxisCount: 4,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
padding: const EdgeInsets.fromLTRB(12, 4, 12, 12),
childAspectRatio: 1.8,
children: [
for (final y in years)
_PickCell(
key: Key('harvestPicker.year.$y'),
label: '$y',
selected: y == selected,
onTap: () => onSelect(y),
),
],
);
}
}
/// A round-highlight cell used for both months and years, matching the seed
/// theme (green pill when selected).
class _PickCell extends StatelessWidget {
const _PickCell({
required this.label,
required this.selected,
required this.onTap,
super.key,
});
final String label;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(4),
child: Material(
color: selected ? seedGreen : Colors.transparent,
shape: const StadiumBorder(),
child: InkWell(
customBorder: const StadiumBorder(),
onTap: onTap,
child: Center(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: selected ? Colors.white : null,
fontWeight: selected ? FontWeight.w600 : null,
),
),
),
),
),
);
}
}