tane/apps/app_seeds/lib/ui/variety_detail_screen.dart
vjrj fde0ea6eab feat(species): derived verified reference links (GBIF/Wikipedia/Wikispecies)
Add a read-only "Learn more" section on the variety detail that surfaces
verified external references for the linked species, built deterministically
from bundled identifiers — no titles bundled, no network, offline-first:

- GBIF taxon page from the backbone key
- Wikipedia via Wikidata Special:GoToLinkedPage (locale-aware, RTL-safe,
  falls back to the Wikidata item when a language lacks the article)
- Wikispecies from the scientific name

Enrich the bundled catalog (species.json v3) with gbif_key + wikidata_qid for
all 14 species, cross-checked (each QID carries GBIF ID P846 = the key).
Schema already had the columns; seedBundled now persists and backfills them.

The grower's own manual links stay but are demoted (progressive disclosure):
the section only shows when links exist and "Add link" is a low-emphasis
button, since hand-adding a link is the rarer power move.

Pure-Dart link builder is unit-tested; repo persistence/backfill and the
detail-screen section are covered too.
2026-07-09 22:44:12 +02:00

2081 lines
70 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: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/species_repository.dart';
import '../data/variety_repository.dart';
import '../db/enums.dart';
import '../domain/crop_calendar.dart';
import '../domain/seed_viability.dart';
import '../domain/species_reference_links.dart';
import '../i18n/strings.g.dart';
import '../state/variety_detail_cubit.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),
),
],
),
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 (_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,
],
),
// Germination only applies to seed lots, not to plantel.
trailing: lot.type != LotType.seed
? null
: 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),
),
],
),
);
}
}
/// 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: 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;
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;
@override
void dispose() {
_name.dispose();
_category.dispose();
_notes.dispose();
_speciesField.dispose();
super.dispose();
}
Future<void> _onSpeciesQuery(String query) async {
final lang = Localizations.localeOf(context).languageCode;
final results = await widget.species.search(query, languageCode: lang);
if (mounted) setState(() => _suggestions = results);
}
void _pick(SpeciesMatch match) {
setState(() {
_pickedSpeciesId = match.id;
_speciesField.text = match.displayLabel;
_suggestions = const [];
});
}
void _save() {
final name = _name.text.trim();
widget.cubit.updateFields(
label: name.isEmpty ? null : name,
category: _nullIfBlank(_category.text),
notes: _nullIfBlank(_notes.text),
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,
),
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)),
),
),
),
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),
),
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(', '),
),
],
),
),
),
],
);
}
}
/// 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: 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;
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,
),
),
],
// 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);
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,
);
} else {
cubit.addLot(
type: selectedType,
year: selectedYear,
month: selectedMonth,
quantity: selectedQuantity,
presentation: selectedPresentation,
originName: originName,
originPlace: originPlace,
abundance: selectedAbundance,
preservationFormat: selectedPreservation,
offerStatus: selectedShare,
);
}
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,
),
),
);
}
}