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( 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), 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 = [ 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 _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: 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 _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( 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 _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( 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 _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(); 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, ), ), ); } }