tane/apps/app_seeds/lib/ui/variety_detail_screen.dart
vjrj 269fb59070 feat(handover): one hand-over sheet replaces the separate sale/plantare doors
'Seeds changed hands' is now the single entry point on the variety
detail (and per lot row): direction (gave/received), one-tap 'all of it'
(default — the whole-plant/shrub case), optional partial quantity,
counterparty, and reveal-on-tap money and return-promise sections that
spawn the Sale/Plantare rows through recordHandover in one save.

Zero-lot varieties still work (ledger rows only); several lots get a
chip picker. The Sales/Plantares screens keep their own add buttons for
off-app records — the sheets stay, demoted to secondary.

i18n handover block in en/es/pt/ast; widget tests for the flow.
2026-07-11 08:00:45 +02:00

2418 lines
84 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 'dart:async';
import 'dart:typed_data';
import 'package:drift/drift.dart' show Value;
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:url_launcher/url_launcher.dart';
import '../data/seed_saving_catalog.dart';
import '../data/species_repository.dart';
import '../data/variety_repository.dart';
import '../db/enums.dart';
import '../domain/crop_calendar.dart';
import '../domain/seed_saving.dart';
import '../domain/seed_viability.dart';
import '../domain/species_reference_links.dart';
import '../i18n/strings.g.dart';
import '../state/variety_detail_cubit.dart';
import 'currency_quick_picks.dart';
import 'handover_sheet.dart';
import 'harvest_date_picker.dart';
import 'photo_pick.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),
),
],
),
const SizedBox(height: 16),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
// ONE door for "seeds changed hands": gift, swap or sale, with
// or without money or a return promise. The sales/plantares
// screens keep their own add buttons for off-app records.
OutlinedButton.icon(
key: const Key('detail.handover'),
icon: const Icon(Icons.handshake_outlined, size: 18),
onPressed: () => showHandoverSheet(
context,
repository: context.read<VarietyRepository>(),
varietyId: detail.id,
lots: detail.lots,
),
label: Text(t.handover.title),
),
],
),
if (detail.notes != null && detail.notes!.trim().isNotEmpty) ...[
const SizedBox(height: 16),
_SectionTitle(t.detail.notes),
Text(detail.notes!),
],
if (detail.hasCropCalendar) ...[
const SizedBox(height: 16),
_SectionTitle(t.cropCalendar.title),
_CropCalendarView(detail: detail),
],
if (SeedSavingCatalog.guideFor(
scientificName: detail.scientificName,
family: detail.family ?? detail.category,
) case final guide? when guide.hasAny) ...[
const SizedBox(height: 16),
_SectionTitle(t.seedSaving.title),
_SeedSavingView(guide: guide),
],
if (_referenceLinks(context, detail) case final references
when references.isNotEmpty) ...[
const SizedBox(height: 16),
_SectionTitle(t.detail.reference),
Wrap(
spacing: 8,
runSpacing: 4,
children: [
for (final ref in references)
ActionChip(
key: Key('ref.${ref.source.name}'),
avatar: const Icon(Icons.open_in_new, size: 18),
label: Text(_referenceLabel(t, ref.source)),
onPressed: () => _openUrl(ref.url),
),
],
),
],
// The grower's own links: a rarer power move, so kept subtle — the
// section only appears once there are links, and adding one lives
// behind a low-emphasis button rather than a prominent chip.
if (detail.links.isNotEmpty) ...[
const SizedBox(height: 16),
_SectionTitle(t.detail.links),
Wrap(
spacing: 8,
runSpacing: 4,
children: [
for (final link in detail.links)
InputChip(
key: Key('link.${link.id}'),
avatar: const Icon(Icons.link, size: 18),
label: Text(link.display),
deleteIcon: const Icon(Icons.close, size: 18),
onDeleted: () => cubit.removeExternalLink(link.id),
onPressed: () => _openUrl(link.url),
),
],
),
],
Align(
alignment: AlignmentDirectional.centerStart,
child: TextButton.icon(
key: const Key('link.add'),
onPressed: () => _showAddLinkDialog(context, cubit),
icon: const Icon(Icons.add_link, size: 18),
label: Text(t.detail.addLink),
),
),
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)
_LotTile(
cubit: cubit,
lot: lot,
viabilityYears: detail.viabilityYears,
),
],
),
);
}
}
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!)
else if (lot.abundance != null)
abundanceLabel(t, lot.abundance!),
if (lot.presentation != null) presentationLabel(t, lot.presentation!),
if (lot.offerStatus != OfferStatus.private)
shareStatusLabel(t, lot.offerStatus),
];
return parts.join(' · ');
}
/// The provenance line for a lot ("from · place"), or null when neither is set.
String? _lotOrigin(VarietyLot lot) {
final parts = <String>[?lot.originName, ?lot.originPlace];
return parts.isEmpty ? null : parts.join(' · ');
}
String _germinationPercent(Translations t, double rate) =>
t.germination.result(percent: (rate * 100).round());
/// One lot row: kind, harvest/quantity summary, storage location, an aging
/// (viability) warning for seed lots, and the germination test affordance.
class _LotTile extends StatelessWidget {
const _LotTile({
required this.cubit,
required this.lot,
required this.viabilityYears,
});
final VarietyDetailCubit cubit;
final VarietyLot lot;
/// Typical seed longevity of the variety's species (years), or null.
final int? viabilityYears;
@override
Widget build(BuildContext context) {
final t = context.t;
// Aging only makes sense for seed lots (living material is not stored dry).
final viability = lot.type == LotType.seed
? seedViability(
harvestYear: lot.harvestYear,
viabilityYears: viabilityYears,
currentYear: DateTime.now().year,
)
: SeedViability.unknown;
final warning = _ViabilityWarning.forStatus(viability, viabilityYears);
return 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: Row(
children: [
_LotTypeChip(type: lot.type),
const SizedBox(width: 8),
Expanded(child: Text(_lotSubtitle(t, lot))),
],
),
subtitle:
(lot.storageLocation == null &&
warning == null &&
_lotOrigin(lot) == null)
? null
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (_lotOrigin(lot) case final origin?)
Row(
children: [
const Icon(Icons.place_outlined, size: 14),
const SizedBox(width: 4),
Expanded(child: Text(origin)),
],
),
if (lot.storageLocation case final loc?) Text(loc),
?warning,
],
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (lot.type == LotType.seed && lot.latestGerminationRate != null)
_GerminationBadge(rate: lot.latestGerminationRate!),
IconButton(
key: Key('lot.handover.${lot.id}'),
icon: const Icon(Icons.handshake_outlined),
tooltip: t.handover.title,
onPressed: () => showHandoverSheet(
context,
repository: context.read<VarietyRepository>(),
varietyId: cubit.varietyId,
lots: [lot],
),
),
// Germination only applies to seed lots, not to plantel.
if (lot.type == LotType.seed)
IconButton(
key: Key('lot.germination.${lot.id}'),
icon: const Icon(Icons.grass_outlined),
tooltip: t.germination.title,
onPressed: () => _showGerminationSheet(context, cubit, lot),
),
],
),
);
}
}
/// An inline "sow/reproduce this seed soon" line for an aging seed lot. Renders
/// nothing (returns null) when the lot is fresh or its age can't be judged.
class _ViabilityWarning extends StatelessWidget {
const _ViabilityWarning._({
required this.status,
required this.viabilityYears,
});
static _ViabilityWarning? forStatus(SeedViability status, int? years) {
if (status != SeedViability.expiringSoon &&
status != SeedViability.expired) {
return null;
}
return _ViabilityWarning._(status: status, viabilityYears: years);
}
final SeedViability status;
final int? viabilityYears;
@override
Widget build(BuildContext context) {
final t = context.t;
final scheme = Theme.of(context).colorScheme;
final expired = status == SeedViability.expired;
final color = expired ? scheme.error : const Color(0xFFB26A00);
final years = viabilityYears;
final text = expired
? (years == null
? t.viability.expired
: t.viability.expiredYears(years: years))
: (years == null
? t.viability.expiringSoon
: t.viability.expiringSoonYears(years: years));
return Padding(
key: Key('lot.viability.${expired ? 'expired' : 'soon'}'),
padding: const EdgeInsets.only(top: 2),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
expired ? Icons.error_outline : Icons.schedule,
size: 14,
color: color,
),
const SizedBox(width: 4),
Flexible(
child: Text(
text,
style: TextStyle(
color: color,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
),
],
),
);
}
}
/// A small pill making a lot's kind (seeds vs plants/plantel) unmistakable.
class _LotTypeChip extends StatelessWidget {
const _LotTypeChip({required this.type});
final LotType type;
@override
Widget build(BuildContext context) {
final t = context.t;
final isSeed = type == LotType.seed;
// Seed lots read green; living/vegetative forms read teal.
final color = isSeed ? seedGreenDark : const Color(0xFF00796B);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (isSeed)
SeedGlyph(SeedGlyphs.jars, size: 14, color: color)
else
Icon(iconForLotType(type), size: 14, color: color),
const SizedBox(width: 4),
Text(
lotTypeLabel(t, type),
style: TextStyle(
fontSize: 11,
color: color,
fontWeight: FontWeight.w600,
),
),
],
),
);
}
}
/// 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: SingleChildScrollView(
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(
borderRadius: BorderRadius.all(Radius.circular(8)),
),
),
),
),
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(
borderRadius: BorderRadius.all(Radius.circular(8)),
),
),
),
),
],
),
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;
/// A species inferred from the typed name, offered as a one-tap suggestion.
/// Only surfaced while the variety has no species and the grower has not
/// picked one, so it never nags over a deliberate choice.
SpeciesMatch? _suggested;
late bool _isOrganic = widget.detail.isOrganic;
late bool _needsReproduction = widget.detail.needsReproduction;
late int? _sowMonths = widget.detail.sowMonths;
late int? _transplantMonths = widget.detail.transplantMonths;
late int? _floweringMonths = widget.detail.floweringMonths;
late int? _fruitingMonths = widget.detail.fruitingMonths;
late int? _seedHarvestMonths = widget.detail.seedHarvestMonths;
late bool _calendarOpen = widget.detail.hasCropCalendar;
// Typing stays responsive by not touching the DB on every keystroke: each
// field waits until the grower pauses before running its (heavy) lookup, and
// a per-field query counter drops any result that a later keystroke has
// already superseded, so stale suggestions never flicker back in.
static const _debounce = Duration(milliseconds: 250);
Timer? _speciesDebounce;
Timer? _nameDebounce;
int _speciesQueryId = 0;
int _nameQueryId = 0;
@override
void dispose() {
_speciesDebounce?.cancel();
_nameDebounce?.cancel();
_name.dispose();
_category.dispose();
_notes.dispose();
_speciesField.dispose();
super.dispose();
}
void _onSpeciesQuery(String query) {
_speciesDebounce?.cancel();
_speciesDebounce = Timer(_debounce, () => _runSpeciesQuery(query));
}
Future<void> _runSpeciesQuery(String query) async {
final queryId = ++_speciesQueryId;
final lang = Localizations.localeOf(context).languageCode;
final results = await widget.species.search(query, languageCode: lang);
// A manual species search takes over from the name-based suggestion.
if (mounted && queryId == _speciesQueryId) {
setState(() => _suggestions = results);
}
}
void _onNameChanged(String name) {
if (widget.detail.speciesId != null || _pickedSpeciesId != null) return;
_nameDebounce?.cancel();
_nameDebounce = Timer(_debounce, () => _runNameClassify(name));
}
/// As the name is typed, offer the species it names — but only when the
/// variety has no species yet and the grower hasn't picked one, so an
/// existing link is never second-guessed.
Future<void> _runNameClassify(String name) async {
if (widget.detail.speciesId != null || _pickedSpeciesId != null) return;
final queryId = ++_nameQueryId;
final lang = Localizations.localeOf(context).languageCode;
final match = await widget.species.classifyLabel(name, languageCode: lang);
if (mounted && queryId == _nameQueryId) {
setState(() => _suggested = match);
}
}
void _pick(SpeciesMatch match) {
setState(() {
_pickedSpeciesId = match.id;
_speciesField.text = match.displayLabel;
_suggestions = const [];
_suggested = null;
});
}
void _save() {
final name = _name.text.trim();
widget.cubit.updateFields(
label: name.isEmpty ? null : name,
category: _nullIfBlank(_category.text),
notes: _nullIfBlank(_notes.text),
isOrganic: _isOrganic,
needsReproduction: _needsReproduction,
sowMonths: Value(_sowMonths),
transplantMonths: Value(_transplantMonths),
floweringMonths: Value(_floweringMonths),
fruitingMonths: Value(_fruitingMonths),
seedHarvestMonths: Value(_seedHarvestMonths),
);
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,
),
// Fields scroll; the Cancel/Save row is pinned as a fixed footer so Save
// stays visible and tappable no matter how tall the sheet grows.
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Flexible(
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,
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(
labelText: t.editVariety.name,
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(8)),
),
),
onChanged: _onNameChanged,
),
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(
borderRadius: BorderRadius.all(Radius.circular(8)),
),
),
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),
),
// One-tap suggestion inferred from the name (only when the
// species field is idle, so it never competes with a manual
// search).
if (_suggested case final suggested?
when _suggestions.isEmpty && _pickedSpeciesId == null)
ListTile(
dense: true,
key: const Key('editVariety.speciesSuggestion'),
leading: const Icon(Icons.auto_awesome_outlined),
title: Text(suggested.displayLabel),
subtitle: Text(t.editVariety.speciesSuggested),
onTap: () => _pick(suggested),
),
const SizedBox(height: 12),
TextField(
controller: _category,
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(
labelText: t.editVariety.category,
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(8)),
),
),
),
const SizedBox(height: 12),
TextField(
controller: _notes,
minLines: 2,
maxLines: 5,
decoration: InputDecoration(
labelText: t.editVariety.notes,
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(8)),
),
),
),
const SizedBox(height: 4),
SwitchListTile(
key: const Key('editVariety.organic'),
contentPadding: EdgeInsets.zero,
secondary: const Icon(Icons.eco_outlined, color: seedGreen),
title: Text(t.editVariety.organic),
subtitle: Text(t.editVariety.organicHint),
value: _isOrganic,
onChanged: (v) => setState(() => _isOrganic = v),
),
SwitchListTile(
key: const Key('editVariety.needsReproduction'),
contentPadding: EdgeInsets.zero,
secondary: const Icon(Icons.autorenew, color: seedGreen),
title: Text(t.needsReproduction.label),
subtitle: Text(t.needsReproduction.hint),
value: _needsReproduction,
onChanged: (v) => setState(() => _needsReproduction = v),
),
// Crop calendar: one collapsed reveal, not five dropdowns up front.
ExpansionTile(
key: const Key('editVariety.cropCalendar'),
initiallyExpanded: _calendarOpen,
tilePadding: EdgeInsets.zero,
childrenPadding: const EdgeInsets.only(bottom: 8),
leading: const Icon(Icons.calendar_month_outlined),
title: Text(t.cropCalendar.title),
onExpansionChanged: (v) => _calendarOpen = v,
children: [
_MonthMultiSelect(
label: t.cropCalendar.sow,
mask: _sowMonths,
onChanged: (m) => setState(() => _sowMonths = m),
),
_MonthMultiSelect(
label: t.cropCalendar.transplant,
mask: _transplantMonths,
onChanged: (m) => setState(() => _transplantMonths = m),
),
_MonthMultiSelect(
label: t.cropCalendar.flowering,
mask: _floweringMonths,
onChanged: (m) => setState(() => _floweringMonths = m),
),
_MonthMultiSelect(
label: t.cropCalendar.fruiting,
mask: _fruitingMonths,
onChanged: (m) => setState(() => _fruitingMonths = m),
),
_MonthMultiSelect(
label: t.cropCalendar.seedHarvest,
mask: _seedHarvestMonths,
onChanged: (m) =>
setState(() => _seedHarvestMonths = m),
),
],
),
],
),
),
),
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),
),
],
),
],
),
);
}
}
/// A labelled multi-select of the 12 months for one crop-calendar phase — a
/// phase can span several months. [mask] is a 12-bit month mask; tapping a
/// month toggles its bit.
class _MonthMultiSelect extends StatelessWidget {
const _MonthMultiSelect({
required this.label,
required this.mask,
required this.onChanged,
});
final String label;
final int? mask;
final ValueChanged<int?> onChanged;
@override
Widget build(BuildContext context) {
final t = context.t;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: Theme.of(context).textTheme.labelLarge),
const SizedBox(height: 6),
Wrap(
spacing: 6,
runSpacing: 4,
children: [
for (var m = 1; m <= 12; m++)
FilterChip(
key: Key('calendar.$label.$m'),
visualDensity: VisualDensity.compact,
labelPadding: const EdgeInsets.symmetric(horizontal: 2),
label: Text(_monthAbbrev(t.harvest.monthNames[m - 1])),
selected: maskHasMonth(mask, m),
onSelected: (_) => onChanged(toggleMonth(mask, m)),
),
],
),
],
),
);
}
}
/// A short (≤3-char) label for a month name, for the compact calendar chips.
String _monthAbbrev(String name) =>
name.length <= 3 ? name : name.substring(0, 3);
/// Read-only crop calendar: one line per recorded phase ("Sow · Mar, Apr, Sep").
/// Renders only the phases that have months set.
class _CropCalendarView extends StatelessWidget {
const _CropCalendarView({required this.detail});
final VarietyDetail detail;
@override
Widget build(BuildContext context) {
final t = context.t;
final phases = <(String, int?)>[
(t.cropCalendar.sow, detail.sowMonths),
(t.cropCalendar.transplant, detail.transplantMonths),
(t.cropCalendar.flowering, detail.floweringMonths),
(t.cropCalendar.fruiting, detail.fruitingMonths),
(t.cropCalendar.seedHarvest, detail.seedHarvestMonths),
];
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final (label, mask) in phases)
if (maskToMonths(mask).isNotEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Text.rich(
TextSpan(
children: [
TextSpan(
text: '$label · ',
style: const TextStyle(fontWeight: FontWeight.w600),
),
TextSpan(
text: maskToMonths(mask)
.map((m) => _monthAbbrev(t.harvest.monthNames[m - 1]))
.join(', '),
),
],
),
),
),
],
);
}
}
/// "How to save this seed" — the bundled seed-saving guidance for the crop
/// (pollination, isolation distance, how many plants, wet/dry, difficulty, a
/// tip), in human words. Shown only when a guide resolves for the variety.
class _SeedSavingView extends StatelessWidget {
const _SeedSavingView({required this.guide});
final SeedSavingGuide guide;
@override
Widget build(BuildContext context) {
final t = context.t;
final rows = <Widget>[];
final cycle = switch (guide.lifeCycle) {
LifeCycle.annual => t.seedSaving.cycleAnnual,
LifeCycle.biennial => t.seedSaving.cycleBiennial,
LifeCycle.perennial => t.seedSaving.cyclePerennial,
null => null,
};
if (cycle != null) {
rows.add(_line(Icons.event_repeat, t.seedSaving.lifeCycle, cycle));
}
if (guide.pollination case final poll?) {
final base = switch (poll) {
Pollination.self => t.seedSaving.pollSelf,
Pollination.cross => t.seedSaving.pollCross,
Pollination.mixed => t.seedSaving.pollMixed,
};
final via = poll == Pollination.self
? ''
: switch (guide.vector) {
PollinationVector.insect => ' · ${t.seedSaving.byInsect}',
PollinationVector.wind => ' · ${t.seedSaving.byWind}',
_ => '',
};
final icon = switch (guide.vector) {
PollinationVector.wind => Icons.air,
PollinationVector.insect => Icons.emoji_nature,
_ => Icons.lock_outline,
};
rows.add(_line(icon, t.seedSaving.pollination, '$base$via'));
}
if (guide.isolationMinM case final min?) {
final max = guide.isolationMaxM;
final value = (max != null && max != min)
? t.seedSaving.isolationRange(min: min, max: max)
: t.seedSaving.isolationSingle(min: min);
rows.add(_line(Icons.social_distance, t.seedSaving.isolation, value));
}
if ((guide.recommendedPlants ?? guide.minPlants) case final n?) {
rows.add(_line(
Icons.groups_outlined, t.seedSaving.plants, t.seedSaving.plantsValue(n: n)));
}
if (guide.processing case final proc?) {
final wet = proc == SeedProcessing.wet;
rows.add(_line(wet ? Icons.water_drop_outlined : Icons.grass,
t.seedSaving.processing, wet ? t.seedSaving.procWet : t.seedSaving.procDry));
}
final note = guide.noteFor(LocaleSettings.currentLocale.languageCode);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
...rows,
if (guide.difficulty case final d?) ...[
const SizedBox(height: 4),
_DifficultyChip(difficulty: d),
],
if (note != null) ...[
const SizedBox(height: 8),
Text(note, style: const TextStyle(color: seedMuted, height: 1.35)),
],
// Advisory + a light source credit — the figures are general, not local
// gospel, and drawn from the seed-saving literature.
const SizedBox(height: 10),
Text(t.seedSaving.advisory,
style: const TextStyle(
color: seedMuted, fontSize: 12, fontStyle: FontStyle.italic)),
if (SeedSavingCatalog.sources case final sources when sources.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
'${t.seedSaving.sourcePrefix}: '
'${sources.map((s) => s.title).join(' · ')}',
style: const TextStyle(color: seedMuted, fontSize: 11),
),
),
],
);
}
Widget _line(IconData icon, String label, String value) => Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 18, color: seedGreen),
const SizedBox(width: 10),
Expanded(
child: Text.rich(TextSpan(children: [
TextSpan(
text: '$label · ',
style: const TextStyle(fontWeight: FontWeight.w600)),
TextSpan(text: value),
])),
),
],
),
);
}
class _DifficultyChip extends StatelessWidget {
const _DifficultyChip({required this.difficulty});
final SavingDifficulty difficulty;
@override
Widget build(BuildContext context) {
final t = context.t;
final (label, color) = switch (difficulty) {
SavingDifficulty.easy => (t.seedSaving.diffEasy, const Color(0xFF3B6D11)),
SavingDifficulty.medium =>
(t.seedSaving.diffMedium, const Color(0xFF8A6D1E)),
SavingDifficulty.hard => (t.seedSaving.diffHard, const Color(0xFFA14234)),
};
return Row(
children: [
Icon(Icons.insights, size: 18, color: seedGreen),
const SizedBox(width: 10),
Text('${t.seedSaving.difficulty} · ',
style: const TextStyle(fontWeight: FontWeight.w600)),
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 2),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.14),
borderRadius: BorderRadius.circular(10),
),
child: Text(label,
style: TextStyle(color: color, fontWeight: FontWeight.w600)),
),
],
);
}
}
/// Human label for a coarse-abundance level.
String abundanceLabel(Translations t, Abundance a) => switch (a) {
Abundance.plentyToShare => t.abundance.plentyToShare,
Abundance.enoughToShare => t.abundance.enoughToShare,
Abundance.enoughForMe => t.abundance.enoughForMe,
Abundance.runningLow => t.abundance.runningLow,
};
/// Human label for how (whether) a lot is offered to others.
String shareStatusLabel(Translations t, OfferStatus s) => switch (s) {
OfferStatus.private => t.share.private,
OfferStatus.shared => t.share.gift,
OfferStatus.exchange => t.share.exchange,
OfferStatus.sell => t.share.sell,
};
/// Human label for a seed preservation format.
String preservationLabel(Translations t, PreservationFormat p) => switch (p) {
PreservationFormat.jarWithDesiccant => t.preservation.jarWithDesiccant,
PreservationFormat.glassJar => t.preservation.glassJar,
PreservationFormat.paperEnvelope => t.preservation.paperEnvelope,
PreservationFormat.paperBag => t.preservation.paperBag,
PreservationFormat.plasticBag => t.preservation.plasticBag,
};
/// Human label for a drying-agent (silica) state.
String desiccantLabel(Translations t, DesiccantState d) => switch (d) {
DesiccantState.none => t.desiccant.none,
DesiccantState.add => t.desiccant.add,
DesiccantState.replace => t.desiccant.replace,
DesiccantState.dry => t.desiccant.dry,
DesiccantState.fresh => t.desiccant.fresh,
};
/// Single-select chips for the coarse-abundance level; tapping the current one
/// clears it (null).
class _AbundanceSelector extends StatelessWidget {
const _AbundanceSelector({required this.value, required this.onChanged});
final Abundance? value;
final ValueChanged<Abundance?> onChanged;
@override
Widget build(BuildContext context) {
final t = context.t;
return Wrap(
spacing: 8,
runSpacing: 4,
children: [
for (final a in Abundance.values)
ChoiceChip(
key: Key('abundance.${a.name}'),
label: Text(abundanceLabel(t, a)),
selected: value == a,
onSelected: (sel) => onChanged(sel ? a : null),
),
],
);
}
}
/// Single-select chips for the share terms. Gift and swap come first and sale
/// last, never preselected — the gift is first-class, the sale a choice
/// (sharing-model §4.1). There is always a selection ("just for me" default).
class _ShareSelector extends StatelessWidget {
const _ShareSelector({required this.value, required this.onChanged});
final OfferStatus value;
final ValueChanged<OfferStatus> onChanged;
@override
Widget build(BuildContext context) {
final t = context.t;
return Wrap(
spacing: 8,
runSpacing: 4,
children: [
for (final s in const [
OfferStatus.private,
OfferStatus.shared,
OfferStatus.exchange,
OfferStatus.sell,
])
ChoiceChip(
key: Key('share.${s.name}'),
label: Text(shareStatusLabel(t, s)),
selected: value == s,
onSelected: (sel) {
if (sel) onChanged(s);
},
),
],
);
}
}
/// Single-select chips for the preservation format; tapping the current one
/// clears it (null).
class _PreservationSelector extends StatelessWidget {
const _PreservationSelector({required this.value, required this.onChanged});
final PreservationFormat? value;
final ValueChanged<PreservationFormat?> onChanged;
@override
Widget build(BuildContext context) {
final t = context.t;
return Wrap(
spacing: 8,
runSpacing: 4,
children: [
for (final p in PreservationFormat.values)
ChoiceChip(
key: Key('preservation.${p.name}'),
label: Text(preservationLabel(t, p)),
selected: value == p,
onSelected: (sel) => onChanged(sel ? p : null),
),
],
);
}
}
/// Lists a lot's storage-condition checks (newest first) and offers to add one.
class _ConditionChecksBlock extends StatelessWidget {
const _ConditionChecksBlock({required this.cubit, required this.lot});
final VarietyDetailCubit cubit;
final VarietyLot lot;
@override
Widget build(BuildContext context) {
final t = context.t;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Align(
alignment: Alignment.centerLeft,
child: Text(
t.conditionCheck.title,
style: Theme.of(context).textTheme.labelLarge,
),
),
if (lot.conditionChecks.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Text(t.conditionCheck.none),
)
else
for (final check in lot.conditionChecks)
ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.inventory_2_outlined),
title: Text(
t.conditionCheck.summary(
count: check.containerCount?.toString() ?? '',
state: check.desiccantState == null
? ''
: desiccantLabel(t, check.desiccantState!),
),
),
),
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
key: const Key('conditionCheck.open'),
icon: const Icon(Icons.add),
label: Text(t.conditionCheck.add),
onPressed: () => _showConditionSheet(context, cubit, lot),
),
),
],
);
}
}
Future<void> _showConditionSheet(
BuildContext context,
VarietyDetailCubit cubit,
VarietyLot lot,
) {
final t = context.t;
final containers = TextEditingController();
DesiccantState? state;
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: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
t.conditionCheck.title,
style: Theme.of(sheetContext).textTheme.titleLarge,
),
const SizedBox(height: 12),
TextField(
key: const Key('conditionCheck.containers'),
controller: containers,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: t.conditionCheck.containers,
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(8)),
),
),
),
const SizedBox(height: 16),
Text(
t.conditionCheck.desiccant,
style: Theme.of(sheetContext).textTheme.labelLarge,
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 4,
children: [
for (final d in DesiccantState.values)
ChoiceChip(
key: Key('desiccant.${d.name}'),
label: Text(desiccantLabel(t, d)),
selected: state == d,
onSelected: (sel) =>
setState(() => state = sel ? d : null),
),
],
),
const SizedBox(height: 16),
Row(
children: [
TextButton(
onPressed: () => Navigator.of(sheetContext).pop(),
child: Text(t.common.cancel),
),
const Spacer(),
FilledButton(
key: const Key('conditionCheck.save'),
onPressed: () {
cubit.addConditionCheck(
lotId: lot.id,
checkedOn: DateTime.now().millisecondsSinceEpoch,
containerCount: int.tryParse(containers.text.trim()),
desiccantState: state,
);
Navigator.of(sheetContext).pop();
},
child: Text(t.conditionCheck.add),
),
],
),
],
),
),
),
),
);
}
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;
var selectedPresentation = existing?.presentation;
final editing = existing != null;
// Provenance + abundance + preservation: hidden behind reveal-on-tap chips so
// the default form stays small (progressive disclosure).
final originNameCtrl = TextEditingController(
text: existing?.originName ?? '',
);
final originPlaceCtrl = TextEditingController(
text: existing?.originPlace ?? '',
);
var selectedAbundance = existing?.abundance;
var selectedPreservation = existing?.preservationFormat;
var selectedShare = existing?.offerStatus ?? OfferStatus.private;
var showOrigin =
existing?.originName != null || existing?.originPlace != null;
var showAbundance = existing?.abundance != null;
var showShare = selectedShare != OfferStatus.private;
final existingPrice = existing?.priceAmount;
final priceAmountCtrl = TextEditingController(
// Drop a trailing .0 so the field shows "5", not "5.0".
text: existingPrice == null
? ''
: (existingPrice == existingPrice.roundToDouble()
? existingPrice.toInt().toString()
: existingPrice.toString()),
);
final priceCurrencyCtrl = TextEditingController(
text: existing?.priceCurrency ?? '',
);
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: [
Flexible(
child: SingleChildScrollView(
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;
if (!typeHasPresentation(type)) {
selectedPresentation = null;
}
}),
),
if (typeHasPresentation(selectedType)) ...[
const SizedBox(height: 16),
Text(
t.presentation.title,
style: Theme.of(sheetContext).textTheme.labelLarge,
),
const SizedBox(height: 8),
PresentationSelector(
value: selectedPresentation,
onChanged: (p) =>
setState(() => selectedPresentation = p),
),
],
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: 12),
// Layer 1: reveal-on-tap extras, one field per chip.
Wrap(
spacing: 8,
children: [
if (!showOrigin)
ActionChip(
key: const Key('lot.addOrigin'),
avatar: const Icon(Icons.place_outlined, size: 18),
label: Text(t.provenance.section),
onPressed: () => setState(() => showOrigin = true),
),
if (!showAbundance)
ActionChip(
key: const Key('lot.addAbundance'),
avatar: const Icon(
Icons.inventory_2_outlined,
size: 18,
),
label: Text(t.abundance.add),
onPressed: () =>
setState(() => showAbundance = true),
),
if (!showShare)
ActionChip(
key: const Key('lot.addShare'),
avatar: const Icon(
Icons.volunteer_activism_outlined,
size: 18,
),
label: Text(t.share.add),
onPressed: () => setState(() => showShare = true),
),
],
),
if (showOrigin) ...[
const SizedBox(height: 12),
TextField(
key: const Key('lot.originName'),
controller: originNameCtrl,
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(
labelText: t.provenance.seedsFrom,
helperText: t.provenance.seedsFromHint,
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(8)),
),
),
),
const SizedBox(height: 12),
TextField(
key: const Key('lot.originPlace'),
controller: originPlaceCtrl,
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(
labelText: t.provenance.place,
helperText: t.provenance.placeHint,
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(8)),
),
),
),
],
if (showAbundance) ...[
const SizedBox(height: 16),
Text(
t.abundance.title,
style: Theme.of(sheetContext).textTheme.labelLarge,
),
const SizedBox(height: 8),
_AbundanceSelector(
value: selectedAbundance,
onChanged: (a) => setState(() {
selectedAbundance = a;
// The bridge from "how much" to "do you share it":
// declaring plenty reveals the sharing choice. A
// nudge, never a change of the choice itself.
if (a == Abundance.plentyToShare ||
a == Abundance.enoughToShare) {
showShare = true;
}
}),
),
],
if (showShare) ...[
const SizedBox(height: 16),
Text(
t.share.title,
style: Theme.of(sheetContext).textTheme.labelLarge,
),
const SizedBox(height: 8),
_ShareSelector(
value: selectedShare,
onChanged: (s) => setState(() => selectedShare = s),
),
if (selectedShare == OfferStatus.private &&
(selectedAbundance == Abundance.plentyToShare ||
selectedAbundance == Abundance.enoughToShare))
Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(
t.share.nudge,
key: const Key('share.nudge'),
style: Theme.of(sheetContext).textTheme.bodySmall,
),
),
// Price only when selling — informational, agreed and
// paid off-app. Empty amount = "to be agreed".
if (selectedShare == OfferStatus.sell) ...[
const SizedBox(height: 12),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: TextField(
key: const Key('lot.priceAmount'),
controller: priceAmountCtrl,
keyboardType:
const TextInputType.numberWithOptions(
decimal: true,
),
decoration: InputDecoration(
labelText: t.share.price,
helperText: t.share.priceHint,
border: const OutlineInputBorder(),
),
),
),
const SizedBox(width: 12),
Expanded(
child: TextField(
key: const Key('lot.priceCurrency'),
controller: priceCurrencyCtrl,
decoration: InputDecoration(
labelText: t.sale.currency,
border: const OutlineInputBorder(),
),
onChanged: (_) => setState(() {}),
),
),
],
),
const SizedBox(height: 8),
CurrencyQuickPicks(
controller: priceCurrencyCtrl,
keyPrefix: 'lot',
onChanged: () => setState(() {}),
),
],
],
// Layer 2: advanced seed-bank details, collapsed by default.
// Only for seed lots, and (condition checks) an existing lot.
if (selectedType == LotType.seed)
ExpansionTile(
key: const Key('lot.advanced'),
tilePadding: EdgeInsets.zero,
childrenPadding: const EdgeInsets.only(bottom: 8),
leading: const Icon(Icons.inventory_outlined),
title: Text(t.conditionCheck.advanced),
children: [
const SizedBox(height: 4),
Align(
alignment: Alignment.centerLeft,
child: Text(
t.preservation.title,
style: Theme.of(
sheetContext,
).textTheme.labelLarge,
),
),
const SizedBox(height: 8),
_PreservationSelector(
value: selectedPreservation,
onChanged: (p) =>
setState(() => selectedPreservation = p),
),
if (editing) ...[
const SizedBox(height: 8),
_ConditionChecksBlock(cubit: cubit, lot: existing),
],
],
),
],
),
),
),
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: () {
final originName = _nullIfBlank(originNameCtrl.text);
final originPlace = _nullIfBlank(originPlaceCtrl.text);
final priceAmount = double.tryParse(
priceAmountCtrl.text.replaceAll(',', '.').trim(),
);
final priceCurrency = _nullIfBlank(priceCurrencyCtrl.text);
if (editing) {
cubit.updateLot(
lotId: existing.id,
type: selectedType,
year: selectedYear,
month: selectedMonth,
quantity: selectedQuantity,
presentation: selectedPresentation,
originName: originName,
originPlace: originPlace,
abundance: selectedAbundance,
preservationFormat: selectedPreservation,
offerStatus: selectedShare,
priceAmount: priceAmount,
priceCurrency: priceCurrency,
);
} else {
cubit.addLot(
type: selectedType,
year: selectedYear,
month: selectedMonth,
quantity: selectedQuantity,
presentation: selectedPresentation,
originName: originName,
originPlace: originPlace,
abundance: selectedAbundance,
preservationFormat: selectedPreservation,
offerStatus: selectedShare,
priceAmount: priceAmount,
priceCurrency: priceCurrency,
);
}
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,
textCapitalization: TextCapitalization.sentences,
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),
),
],
),
);
}
/// Verified reference links for the variety's linked species, localized to the
/// UI language (drives the locale-aware Wikipedia link). Empty when no species
/// is linked or the catalog entry carries no identifiers.
List<ReferenceLink> _referenceLinks(
BuildContext context,
VarietyDetail detail,
) {
final name = detail.scientificName;
if (name == null) return const [];
return speciesReferenceLinks(
scientificName: name,
gbifKey: detail.gbifKey,
wikidataQid: detail.wikidataQid,
languageCode: Localizations.localeOf(context).languageCode,
);
}
String _referenceLabel(Translations t, ReferenceSource source) =>
switch (source) {
ReferenceSource.gbif => t.detail.refGbif,
ReferenceSource.wikipedia => t.detail.refWikipedia,
ReferenceSource.wikispecies => t.detail.refWikispecies,
};
Future<void> _openUrl(String url) async {
final normalized = url.contains('://') ? url : 'https://$url';
final uri = Uri.tryParse(normalized);
if (uri != null) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
}
Future<void> _showAddLinkDialog(
BuildContext context,
VarietyDetailCubit cubit,
) {
final t = context.t;
final urlController = TextEditingController();
final titleController = TextEditingController();
void submit(BuildContext dialogContext) {
final url = urlController.text.trim();
if (url.isNotEmpty) {
cubit.addExternalLink(url, title: _nullIfBlank(titleController.text));
}
Navigator.of(dialogContext).pop();
}
return showDialog<void>(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(t.detail.addLink),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
key: const Key('link.url'),
controller: urlController,
autofocus: true,
keyboardType: TextInputType.url,
decoration: InputDecoration(labelText: t.detail.linkUrl),
),
const SizedBox(height: 8),
TextField(
key: const Key('link.title'),
controller: titleController,
decoration: InputDecoration(labelText: t.detail.linkTitle),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: Text(t.common.cancel),
),
FilledButton(
key: const Key('link.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 ||
detail.isOrganic ||
detail.needsReproduction;
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (detail.isOrganic) ...[
_OrganicChip(),
const SizedBox(height: 6),
],
if (detail.needsReproduction) ...[
_NeedsReproductionChip(),
const SizedBox(height: 6),
],
if (detail.category != null)
Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.category_outlined,
size: 17,
color: seedGreen,
),
const SizedBox(width: 6),
Flexible(
child: Text(
detail.category!,
style: const TextStyle(
color: seedGreen,
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 small green "Organic" pill shown on an organic variety's detail header.
class _OrganicChip extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
key: const Key('detail.organic'),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: seedGreen.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.eco, size: 15, color: seedGreen),
const SizedBox(width: 4),
Text(
context.t.editVariety.organic,
style: const TextStyle(
color: seedGreen,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
],
),
);
}
}
/// A small "to regrow" pill shown on a variety flagged as needing reproduction.
class _NeedsReproductionChip extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
key: const Key('detail.needsReproduction'),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: seedGreen.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.autorenew, size: 15, color: seedGreen),
const SizedBox(width: 4),
Text(
context.t.needsReproduction.badge,
style: const TextStyle(
color: seedGreen,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
],
),
);
}
}
/// 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 bytes = await pickPhoto(context);
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: [
// Tap a photo to open the full-screen viewer, where you can set the
// cover or delete it. The first photo is the cover.
SizedBox(
height: 140,
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: PageView.builder(
controller: _controller,
itemCount: photos.length,
onPageChanged: (i) => setState(() => _page = i),
itemBuilder: (_, i) => GestureDetector(
key: Key('photo.thumb.$i'),
onTap: () => _openPhotoViewer(context, cubit, i),
child: Image.memory(
photos[i].bytes,
width: 140,
height: 140,
fit: BoxFit.cover,
),
),
),
),
),
const SizedBox(height: 6),
if (photos.length > 1)
_Dots(
count: photos.length,
active: page,
onTap: (i) => _controller.animateToPage(
i,
duration: const Duration(milliseconds: 250),
curve: Curves.easeInOut,
),
),
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 _Dots extends StatelessWidget {
const _Dots({required this.count, required this.active, this.onTap});
final int count;
final int active;
final ValueChanged<int>? onTap;
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
for (var i = 0; i < count; i++)
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap == null ? null : () => onTap!(i),
child: Padding(
// Bigger, tappable hit area around each dot.
padding: const EdgeInsets.all(6),
child: Container(
width: 8,
height: 8,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: i == active ? seedGreen : Colors.black26,
),
),
),
),
],
);
}
}
void _openPhotoViewer(
BuildContext context,
VarietyDetailCubit cubit,
int initialIndex,
) {
Navigator.of(context).push(
MaterialPageRoute<void>(
fullscreenDialog: true,
// Re-provide the cubit so the pushed route can set the cover / delete and
// rebuild live as the photo list reorders or shrinks.
builder: (_) => BlocProvider.value(
value: cubit,
child: _PhotoViewer(initialIndex: initialIndex),
),
),
);
}
/// Full-screen, swipeable, pinch-to-zoom photo viewer. Its app bar hosts the
/// per-photo actions (set as cover, delete) — the thumbnail gallery only opens
/// it. Stays in sync with the cubit, so deleting the last photo closes it.
class _PhotoViewer extends StatefulWidget {
const _PhotoViewer({required this.initialIndex});
final int initialIndex;
@override
State<_PhotoViewer> createState() => _PhotoViewerState();
}
class _PhotoViewerState extends State<_PhotoViewer> {
late final PageController _controller = PageController(
initialPage: widget.initialIndex,
);
late int _page = widget.initialIndex;
bool _popped = false;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Future<void> _confirmDelete(
VarietyDetailCubit cubit,
VarietyPhoto photo,
) async {
final t = context.t;
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
content: Text(t.photo.deleteConfirm),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: Text(t.common.cancel),
),
FilledButton(
key: const Key('photo.deleteConfirm'),
onPressed: () => Navigator.of(dialogContext).pop(true),
child: Text(t.common.delete),
),
],
),
);
if (confirmed ?? false) await cubit.removePhoto(photo.id);
}
@override
Widget build(BuildContext context) {
final cubit = context.read<VarietyDetailCubit>();
return BlocBuilder<VarietyDetailCubit, VarietyDetailState>(
builder: (context, state) {
final photos = state.detail?.photos ?? const <VarietyPhoto>[];
// Nothing left to show (last photo deleted / variety gone): close once.
// The guard stops the post-frame callback re-arming every rebuild.
if (photos.isEmpty) {
if (!_popped) {
_popped = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) Navigator.of(context).maybePop();
});
}
return const Scaffold(backgroundColor: Colors.black);
}
final page = _page.clamp(0, photos.length - 1);
// A delete can leave the pager past the end: snap it back. jumpToPage
// fires onPageChanged, which updates _page — so no setState here (that
// would re-arm the callback and spin the frame loop).
if (page != _page && _controller.hasClients) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _controller.hasClients) _controller.jumpToPage(page);
});
}
final isCover = page == 0;
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
backgroundColor: Colors.black,
foregroundColor: Colors.white,
elevation: 0,
actions: [
if (photos.length > 1)
IconButton(
key: const Key('photo.cover'),
icon: Icon(isCover ? Icons.star : Icons.star_border),
// The cover star is disabled; without an explicit disabled
// color it renders faded/dark on the black bar. Amber both
// keeps the contrast and reads as "selected".
color: Colors.white,
disabledColor: Colors.amber,
tooltip: isCover
? context.t.photo.isCover
: context.t.photo.setAsCover,
onPressed: isCover
? null
: () async {
await cubit.setPreferredPhoto(photos[page].id);
// The chosen photo is now the cover (index 0): follow
// it there so the filled star reflects the change.
if (mounted && _controller.hasClients) {
_controller.jumpToPage(0);
setState(() => _page = 0);
}
},
),
IconButton(
key: const Key('photo.delete'),
icon: const Icon(Icons.delete_outline),
tooltip: context.t.common.delete,
onPressed: () => _confirmDelete(cubit, photos[page]),
),
],
),
body: PageView.builder(
controller: _controller,
itemCount: photos.length,
onPageChanged: (i) => setState(() => _page = i),
itemBuilder: (_, i) => _ZoomablePhoto(bytes: photos[i].bytes),
),
);
},
);
}
}
/// Pinch-to-zoom photo that only pans while zoomed, so a plain horizontal
/// swipe still pages the surrounding [PageView].
class _ZoomablePhoto extends StatefulWidget {
const _ZoomablePhoto({required this.bytes});
final Uint8List bytes;
@override
State<_ZoomablePhoto> createState() => _ZoomablePhotoState();
}
class _ZoomablePhotoState extends State<_ZoomablePhoto> {
final _controller = TransformationController();
bool _zoomed = false;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return InteractiveViewer(
transformationController: _controller,
minScale: 1,
maxScale: 5,
panEnabled: _zoomed,
onInteractionEnd: (_) {
final zoomed = _controller.value.getMaxScaleOnAxis() > 1.01;
if (zoomed != _zoomed) setState(() => _zoomed = zoomed);
},
child: Center(child: Image.memory(widget.bytes)),
);
}
}
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,
),
),
);
}
}