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

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