Two save-button sheets wrapped their body in a bare Column (no scroll): the germination sheet (which grows a list of past tests) and the condition-check sheet. On a small phone — especially with the keyboard up — the Save button was pushed off the bottom, unreachable. Both now wrap the body in a SingleChildScrollView, like the other input sheets already do. Also extends the small-phone overflow guard to the profile (identity card + Save), about, intro carousel, and the market/chat screens in their offline 'set up sharing' state. 17 cases green at 320x568 across es/pt/ast.
2159 lines
74 KiB
Dart
2159 lines
74 KiB
Dart
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/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: 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(', '),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 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;
|
||
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,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|