import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.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 '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( 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(); 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(), ), ), 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), if (detail.vernacularNames.isNotEmpty) ...[ const SizedBox(height: 16), _SectionTitle(t.detail.names), Wrap( spacing: 8, children: [ for (final name in detail.vernacularNames) Chip(label: Text(name)), ], ), ], 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 = [ if (lot.harvestYear != null) t.detail.year(year: lot.harvestYear!) 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 _showGerminationSheet( BuildContext context, VarietyDetailCubit cubit, VarietyLot lot, ) { final t = context.t; final sample = TextEditingController(); final germinated = TextEditingController(); return showModalBottomSheet( 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 _confirmDelete( BuildContext context, VarietyDetailCubit cubit, ) async { final t = context.t; final confirmed = await showDialog( 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 _showEditSheet( BuildContext context, VarietyDetailCubit cubit, VarietyDetail detail, SpeciesRepository species, ) { return showModalBottomSheet( 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 _suggestions = const []; String? _pickedSpeciesId; @override void dispose() { _name.dispose(); _category.dispose(); _notes.dispose(); _speciesField.dispose(); super.dispose(); } Future _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: 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 _showLotSheet( BuildContext context, VarietyDetailCubit cubit, { 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 selectedType = existing?.type ?? LotType.seed; var selectedQuantity = existing?.quantity; final editing = existing != null; return showModalBottomSheet( 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), DropdownButtonFormField( 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); }, ), 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, quantity: selectedQuantity, ); } else { cubit.addLot( type: selectedType, year: selectedYear, quantity: selectedQuantity, ); } Navigator.of(sheetContext).pop(); }, 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 (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, ), ), ], ], ); } } 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, ), ), ); } }