tane/apps/app_seeds/lib/ui/harvest_date_picker.dart
vjrj 5d4053157f 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
2026-07-08 12:09:38 +02:00

396 lines
11 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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