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

View file

@ -1,11 +1,13 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:image_picker/image_picker.dart';
import '../data/species_repository.dart';
import '../data/variety_repository.dart';
import '../db/enums.dart';
import '../i18n/strings.g.dart';
import '../state/variety_detail_cubit.dart';
import 'harvest_date_picker.dart';
import 'quantity_kind_l10n.dart';
import 'quantity_picker.dart';
import 'seed_glyph.dart';
@ -155,7 +157,7 @@ class _DetailView extends StatelessWidget {
String _lotSubtitle(Translations t, VarietyLot lot) {
final parts = <String>[
if (lot.harvestYear != null)
t.detail.year(year: lot.harvestYear!)
harvestDateLabel(t, year: lot.harvestYear!, month: lot.harvestMonth)
else
t.detail.noYear,
if (lot.quantity != null) quantityDisplay(t, lot.quantity!),
@ -495,11 +497,8 @@ Future<void> _showLotSheet(
VarietyLot? existing,
}) {
final t = context.t;
final currentYear = DateTime.now().year;
// Harvest years: current year down through the last four decades. Newest
// first because a freshly harvested lot is the common case.
final years = [for (var y = currentYear; y >= currentYear - 40; y--) y];
var selectedYear = existing?.harvestYear ?? currentYear;
var selectedYear = existing?.harvestYear ?? DateTime.now().year;
var selectedMonth = existing?.harvestMonth;
var selectedType = existing?.type ?? LotType.seed;
var selectedQuantity = existing?.quantity;
final editing = existing != null;
@ -547,20 +546,18 @@ Future<void> _showLotSheet(
}),
),
const SizedBox(height: 12),
DropdownButtonFormField<int>(
key: const Key('addLot.year'),
initialValue: selectedYear,
decoration: InputDecoration(
labelText: t.addLot.year,
border: const OutlineInputBorder(),
),
items: [
for (final y in years)
DropdownMenuItem(value: y, child: Text('$y')),
],
onChanged: (value) {
if (value != null) setState(() => selectedYear = value);
},
Text(
t.addLot.year,
style: Theme.of(sheetContext).textTheme.labelLarge,
),
const SizedBox(height: 8),
HarvestDateField(
year: selectedYear,
month: selectedMonth,
onChanged: (year, month) => setState(() {
selectedYear = year;
selectedMonth = month;
}),
),
const SizedBox(height: 16),
Text(
@ -589,12 +586,14 @@ Future<void> _showLotSheet(
lotId: existing.id,
type: selectedType,
year: selectedYear,
month: selectedMonth,
quantity: selectedQuantity,
);
} else {
cubit.addLot(
type: selectedType,
year: selectedYear,
month: selectedMonth,
quantity: selectedQuantity,
);
}
@ -692,18 +691,166 @@ class _DetailHeader extends StatelessWidget {
],
),
),
if (detail.photo != null) ...[
if (hasText) const SizedBox(width: 12),
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.memory(
detail.photo!,
width: 140,
height: 140,
fit: BoxFit.cover,
if (hasText) const SizedBox(width: 12),
_PhotoGallery(detail: detail),
],
);
}
}
/// A 140×140 photo carousel (mockup 07): swipe between photos, page dots, add a
/// photo, and delete the current one. Shows just an "add" button when empty.
class _PhotoGallery extends StatefulWidget {
const _PhotoGallery({required this.detail});
final VarietyDetail detail;
@override
State<_PhotoGallery> createState() => _PhotoGalleryState();
}
class _PhotoGalleryState extends State<_PhotoGallery> {
final _controller = PageController();
int _page = 0;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Future<void> _addPhoto(VarietyDetailCubit cubit) async {
final file = await ImagePicker().pickImage(
source: ImageSource.gallery,
maxWidth: 1280,
imageQuality: 80,
);
final bytes = await file?.readAsBytes();
if (bytes != null) await cubit.addPhoto(bytes);
}
@override
Widget build(BuildContext context) {
final cubit = context.read<VarietyDetailCubit>();
final photos = widget.detail.photos;
if (photos.isEmpty) {
return SizedBox(
width: 140,
height: 140,
child: OutlinedButton(
key: const Key('photo.add'),
onPressed: () => _addPhoto(cubit),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.add_a_photo_outlined),
const SizedBox(height: 8),
Text(context.t.quickAdd.addPhoto, textAlign: TextAlign.center),
],
),
),
);
}
final page = _page.clamp(0, photos.length - 1);
return SizedBox(
width: 140,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
height: 140,
child: Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: PageView.builder(
controller: _controller,
itemCount: photos.length,
onPageChanged: (i) => setState(() => _page = i),
itemBuilder: (_, i) => Image.memory(
photos[i].bytes,
width: 140,
height: 140,
fit: BoxFit.cover,
),
),
),
Positioned(
top: 2,
right: 2,
child: _CircleIconButton(
icon: Icons.close,
tooltip: context.t.common.delete,
onPressed: () => cubit.removePhoto(photos[page].id),
),
),
],
),
),
const SizedBox(height: 6),
if (photos.length > 1) _Dots(count: photos.length, active: page),
TextButton.icon(
key: const Key('photo.add'),
onPressed: () => _addPhoto(cubit),
icon: const Icon(Icons.add_a_photo_outlined, size: 18),
label: Text(context.t.quickAdd.addPhoto),
),
],
),
);
}
}
class _CircleIconButton extends StatelessWidget {
const _CircleIconButton({
required this.icon,
required this.onPressed,
this.tooltip,
});
final IconData icon;
final VoidCallback onPressed;
final String? tooltip;
@override
Widget build(BuildContext context) {
return Material(
color: Colors.black45,
shape: const CircleBorder(),
child: IconButton(
key: const Key('photo.delete'),
icon: Icon(icon, size: 18, color: Colors.white),
tooltip: tooltip,
visualDensity: VisualDensity.compact,
onPressed: onPressed,
),
);
}
}
class _Dots extends StatelessWidget {
const _Dots({required this.count, required this.active});
final int count;
final int active;
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
for (var i = 0; i < count; i++)
Container(
width: 6,
height: 6,
margin: const EdgeInsets.symmetric(horizontal: 2),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: i == active ? seedGreen : Colors.black26,
),
),
],
);
}