tane/apps/app_seeds/lib/ui/variety_detail_screen.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

876 lines
26 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 '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';
import 'theme.dart';
/// Read + edit view of a single variety: its photo, category, notes, other
/// names and lots. Edits go through [VarietyDetailCubit]; the view is reactive.
class VarietyDetailScreen extends StatelessWidget {
const VarietyDetailScreen({super.key});
@override
Widget build(BuildContext context) {
return BlocConsumer<VarietyDetailCubit, VarietyDetailState>(
listenWhen: (prev, curr) => !prev.deleted && curr.deleted,
listener: (context, state) => Navigator.of(context).maybePop(),
builder: (context, state) {
if (state.loading) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
final detail = state.detail;
if (detail == null) {
return Scaffold(
appBar: AppBar(),
body: Center(child: Text(context.t.detail.notFound)),
);
}
return _DetailView(detail: detail);
},
);
}
}
class _DetailView extends StatelessWidget {
const _DetailView({required this.detail});
final VarietyDetail detail;
@override
Widget build(BuildContext context) {
final t = context.t;
final cubit = context.read<VarietyDetailCubit>();
return Scaffold(
appBar: AppBar(
title: Text(detail.label),
actions: [
IconButton(
key: const Key('detail.edit'),
icon: const Icon(Icons.edit_outlined),
tooltip: t.common.edit,
onPressed: () => _showEditSheet(
context,
cubit,
detail,
context.read<SpeciesRepository>(),
),
),
IconButton(
key: const Key('detail.delete'),
icon: const Icon(Icons.delete_outline),
tooltip: t.common.delete,
onPressed: () => _confirmDelete(context, cubit),
),
],
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
_DetailHeader(detail: detail),
const SizedBox(height: 16),
_SectionTitle(t.detail.names),
Wrap(
spacing: 8,
runSpacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
for (final name in detail.vernacularNames)
InputChip(
key: Key('name.${name.id}'),
label: Text(name.name),
deleteIcon: const Icon(Icons.close, size: 18),
onDeleted: () => cubit.removeVernacularName(name.id),
),
ActionChip(
key: const Key('name.add'),
avatar: const Icon(Icons.add, size: 18),
label: Text(t.detail.addName),
onPressed: () => _showAddNameDialog(context, cubit),
),
],
),
if (detail.notes != null && detail.notes!.trim().isNotEmpty) ...[
const SizedBox(height: 16),
_SectionTitle(t.detail.notes),
Text(detail.notes!),
],
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_SectionTitle(t.detail.lots),
TextButton.icon(
key: const Key('detail.addLot'),
onPressed: () => _showLotSheet(context, cubit),
icon: const Icon(Icons.add),
label: Text(t.detail.addLot),
),
],
),
if (detail.lots.isEmpty)
Text(t.detail.noLots)
else
for (final lot in detail.lots)
ListTile(
key: Key('lot.${lot.id}'),
contentPadding: EdgeInsets.zero,
onTap: () => _showLotSheet(context, cubit, existing: lot),
leading: lot.quantity == null
? const Icon(Icons.inventory_2_outlined)
: QuantityKindIcon(lot.quantity!.kind, size: 28),
title: Text(_lotSubtitle(t, lot)),
subtitle: lot.storageLocation == null
? null
: Text(lot.storageLocation!),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (lot.latestGerminationRate != null)
_GerminationBadge(rate: lot.latestGerminationRate!),
IconButton(
key: Key('lot.germination.${lot.id}'),
icon: const Icon(Icons.grass_outlined),
tooltip: t.germination.title,
onPressed: () =>
_showGerminationSheet(context, cubit, lot),
),
],
),
),
],
),
);
}
}
String _lotSubtitle(Translations t, VarietyLot lot) {
final parts = <String>[
if (lot.harvestYear != null)
harvestDateLabel(t, year: lot.harvestYear!, month: lot.harvestMonth)
else
t.detail.noYear,
if (lot.quantity != null) quantityDisplay(t, lot.quantity!),
];
return parts.join(' · ');
}
String _germinationPercent(Translations t, double rate) =>
t.germination.result(percent: (rate * 100).round());
/// A compact pill showing the latest germination rate on a lot tile.
class _GerminationBadge extends StatelessWidget {
const _GerminationBadge({required this.rate});
final double rate;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: scheme.secondaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Text(
_germinationPercent(context.t, rate),
style: TextStyle(
color: scheme.onSecondaryContainer,
fontWeight: FontWeight.w600,
),
),
);
}
}
Future<void> _showGerminationSheet(
BuildContext context,
VarietyDetailCubit cubit,
VarietyLot lot,
) {
final t = context.t;
final sample = TextEditingController();
final germinated = TextEditingController();
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
builder: (sheetContext) => Padding(
padding: EdgeInsets.only(
left: 16,
right: 16,
top: 16,
bottom: MediaQuery.of(sheetContext).viewInsets.bottom + 16,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
t.germination.title,
style: Theme.of(sheetContext).textTheme.titleLarge,
),
const SizedBox(height: 8),
if (lot.germinationTests.isEmpty)
Text(t.germination.none)
else
for (final test in lot.germinationTests)
ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.grass_outlined),
title: Text(
test.rate == null ? '' : _germinationPercent(t, test.rate!),
),
subtitle:
(test.sampleSize != null && test.germinatedCount != null)
? Text('${test.germinatedCount}/${test.sampleSize}')
: null,
),
const Divider(),
Row(
children: [
Expanded(
child: TextField(
key: const Key('germination.germinated'),
controller: germinated,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: t.germination.germinated,
border: const OutlineInputBorder(),
),
),
),
const SizedBox(width: 12),
Expanded(
child: TextField(
key: const Key('germination.sampleSize'),
controller: sample,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: t.germination.sampleSize,
border: const OutlineInputBorder(),
),
),
),
],
),
const SizedBox(height: 16),
Row(
children: [
TextButton(
onPressed: () => Navigator.of(sheetContext).pop(),
child: Text(t.common.cancel),
),
const Spacer(),
FilledButton(
key: const Key('germination.save'),
onPressed: () {
cubit.addGerminationTest(
lotId: lot.id,
testedOn: DateTime.now().millisecondsSinceEpoch,
sampleSize: int.tryParse(sample.text.trim()),
germinatedCount: int.tryParse(germinated.text.trim()),
);
Navigator.of(sheetContext).pop();
},
child: Text(t.germination.add),
),
],
),
],
),
),
);
}
Future<void> _confirmDelete(
BuildContext context,
VarietyDetailCubit cubit,
) async {
final t = context.t;
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
content: Text(t.detail.deleteConfirm),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: Text(t.common.cancel),
),
FilledButton(
key: const Key('detail.deleteConfirm'),
onPressed: () => Navigator.of(dialogContext).pop(true),
child: Text(t.common.delete),
),
],
),
);
if (confirmed ?? false) await cubit.deleteVariety();
}
Future<void> _showEditSheet(
BuildContext context,
VarietyDetailCubit cubit,
VarietyDetail detail,
SpeciesRepository species,
) {
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
builder: (_) =>
_EditVarietySheet(cubit: cubit, detail: detail, species: species),
);
}
/// The edit sheet, stateful so it can search the species catalog live and link
/// the picked species on save.
class _EditVarietySheet extends StatefulWidget {
const _EditVarietySheet({
required this.cubit,
required this.detail,
required this.species,
});
final VarietyDetailCubit cubit;
final VarietyDetail detail;
final SpeciesRepository species;
@override
State<_EditVarietySheet> createState() => _EditVarietySheetState();
}
class _EditVarietySheetState extends State<_EditVarietySheet> {
late final TextEditingController _name = TextEditingController(
text: widget.detail.label,
);
late final TextEditingController _category = TextEditingController(
text: widget.detail.category ?? '',
);
late final TextEditingController _notes = TextEditingController(
text: widget.detail.notes ?? '',
);
late final TextEditingController _speciesField = TextEditingController(
text: widget.detail.scientificName ?? '',
);
List<SpeciesMatch> _suggestions = const [];
String? _pickedSpeciesId;
@override
void dispose() {
_name.dispose();
_category.dispose();
_notes.dispose();
_speciesField.dispose();
super.dispose();
}
Future<void> _onSpeciesQuery(String query) async {
final lang = Localizations.localeOf(context).languageCode;
final results = await widget.species.search(query, languageCode: lang);
if (mounted) setState(() => _suggestions = results);
}
void _pick(SpeciesMatch match) {
setState(() {
_pickedSpeciesId = match.id;
_speciesField.text = match.displayLabel;
_suggestions = const [];
});
}
void _save() {
final name = _name.text.trim();
widget.cubit.updateFields(
label: name.isEmpty ? null : name,
category: _nullIfBlank(_category.text),
notes: _nullIfBlank(_notes.text),
);
if (_pickedSpeciesId != null) {
widget.cubit.linkSpecies(_pickedSpeciesId!);
}
Navigator.of(context).pop();
}
@override
Widget build(BuildContext context) {
final t = context.t;
return Padding(
padding: EdgeInsets.only(
left: 16,
right: 16,
top: 16,
bottom: MediaQuery.of(context).viewInsets.bottom + 16,
),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
t.editVariety.title,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 12),
TextField(
key: const Key('editVariety.name'),
controller: _name,
decoration: InputDecoration(
labelText: t.editVariety.name,
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextField(
key: const Key('editVariety.species'),
controller: _speciesField,
decoration: InputDecoration(
labelText: t.editVariety.species,
hintText: t.editVariety.speciesHint,
prefixIcon: const Icon(Icons.eco_outlined),
border: const OutlineInputBorder(),
),
onChanged: _onSpeciesQuery,
),
for (final match in _suggestions)
ListTile(
dense: true,
key: Key('species.option.${match.id}'),
title: Text(match.displayLabel),
subtitle: match.family == null ? null : Text(match.family!),
onTap: () => _pick(match),
),
const SizedBox(height: 12),
TextField(
controller: _category,
decoration: InputDecoration(
labelText: t.editVariety.category,
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextField(
controller: _notes,
minLines: 2,
maxLines: 5,
decoration: InputDecoration(
labelText: t.editVariety.notes,
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 16),
Row(
children: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(t.common.cancel),
),
const Spacer(),
FilledButton(
key: const Key('editVariety.save'),
onPressed: _save,
child: Text(t.common.save),
),
],
),
],
),
),
);
}
}
Future<void> _showLotSheet(
BuildContext context,
VarietyDetailCubit cubit, {
VarietyLot? existing,
}) {
final t = context.t;
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;
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
builder: (sheetContext) => StatefulBuilder(
builder: (sheetContext, setState) => Padding(
padding: EdgeInsets.only(
left: 16,
right: 16,
top: 16,
bottom: MediaQuery.of(sheetContext).viewInsets.bottom + 16,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Expanded(
child: Text(
editing ? t.detail.editLot : t.addLot.title,
style: Theme.of(sheetContext).textTheme.titleLarge,
),
),
if (editing)
IconButton(
key: const Key('lot.delete'),
icon: const Icon(Icons.delete_outline),
tooltip: t.common.delete,
onPressed: () {
cubit.deleteLot(existing.id);
Navigator.of(sheetContext).pop();
},
),
],
),
const SizedBox(height: 12),
LotTypeSelector(
value: selectedType,
onChanged: (type) => setState(() {
selectedType = type;
selectedQuantity = null;
}),
),
const SizedBox(height: 12),
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(
t.addLot.quantity,
style: Theme.of(sheetContext).textTheme.labelLarge,
),
const SizedBox(height: 8),
QuantityPicker(
type: selectedType,
value: selectedQuantity,
onChanged: (q) => selectedQuantity = q,
),
const SizedBox(height: 16),
Row(
children: [
TextButton(
onPressed: () => Navigator.of(sheetContext).pop(),
child: Text(t.common.cancel),
),
const Spacer(),
FilledButton(
key: const Key('addLot.save'),
onPressed: () {
if (editing) {
cubit.updateLot(
lotId: existing.id,
type: selectedType,
year: selectedYear,
month: selectedMonth,
quantity: selectedQuantity,
);
} else {
cubit.addLot(
type: selectedType,
year: selectedYear,
month: selectedMonth,
quantity: selectedQuantity,
);
}
Navigator.of(sheetContext).pop();
},
child: Text(t.common.save),
),
],
),
],
),
),
),
);
}
Future<void> _showAddNameDialog(
BuildContext context,
VarietyDetailCubit cubit,
) {
final t = context.t;
final controller = TextEditingController();
void submit(BuildContext dialogContext) {
final name = controller.text.trim();
if (name.isNotEmpty) cubit.addVernacularName(name);
Navigator.of(dialogContext).pop();
}
return showDialog<void>(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(t.detail.addName),
content: TextField(
key: const Key('name.field'),
controller: controller,
autofocus: true,
decoration: InputDecoration(labelText: t.editVariety.name),
onSubmitted: (_) => submit(dialogContext),
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: Text(t.common.cancel),
),
FilledButton(
key: const Key('name.save'),
onPressed: () => submit(dialogContext),
child: Text(t.common.save),
),
],
),
);
}
String? _nullIfBlank(String value) {
final trimmed = value.trim();
return trimmed.isEmpty ? null : trimmed;
}
/// Mockup-07 header: category link + scientific name on the left, the photo on
/// the right.
class _DetailHeader extends StatelessWidget {
const _DetailHeader({required this.detail});
final VarietyDetail detail;
@override
Widget build(BuildContext context) {
final hasText = detail.category != null || detail.scientificName != null;
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (detail.category != null)
Text(
detail.category!,
style: const TextStyle(
color: seedLink,
fontWeight: FontWeight.w600,
),
),
if (detail.scientificName != null) ...[
const SizedBox(height: 2),
Text(
detail.scientificName!,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontStyle: FontStyle.italic,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
],
),
),
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,
),
),
],
);
}
}
class _SectionTitle extends StatelessWidget {
const _SectionTitle(this.text);
final String text;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
text,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Theme.of(context).colorScheme.primary,
),
),
);
}
}