Apply a Material 3 seed-green palette and rounded components across the home, inventory, quick-add, quantity picker and variety-detail screens. The full-screen photo viewer can now set any photo as the cover (via attachment sort order) and delete after confirmation. Lot forms and packaging surface as choice chips. Extract About out of Settings into its own screen (app version via package_info_plus, website link). Add es/en strings for the new lot types, presentation, home tagline and About. Update Seedees v2 mockups.
1223 lines
39 KiB
Dart
1223 lines
39 KiB
Dart
import 'dart:typed_data';
|
||
|
||
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 '../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!),
|
||
],
|
||
const SizedBox(height: 16),
|
||
_SectionTitle(t.detail.links),
|
||
Wrap(
|
||
spacing: 8,
|
||
runSpacing: 4,
|
||
crossAxisAlignment: WrapCrossAlignment.center,
|
||
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),
|
||
),
|
||
ActionChip(
|
||
key: const Key('link.add'),
|
||
avatar: const Icon(Icons.add, size: 18),
|
||
label: Text(t.detail.addLink),
|
||
onPressed: () => _showAddLinkDialog(context, cubit),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 16),
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
_SectionTitle(t.detail.lots),
|
||
TextButton.icon(
|
||
key: const Key('detail.addLot'),
|
||
onPressed: () => _showLotSheet(context, cubit),
|
||
icon: const Icon(Icons.add),
|
||
label: Text(t.detail.addLot),
|
||
),
|
||
],
|
||
),
|
||
if (detail.lots.isEmpty)
|
||
Text(t.detail.noLots)
|
||
else
|
||
for (final lot in detail.lots)
|
||
ListTile(
|
||
key: Key('lot.${lot.id}'),
|
||
contentPadding: EdgeInsets.zero,
|
||
onTap: () => _showLotSheet(context, cubit, existing: lot),
|
||
leading: lot.quantity == null
|
||
? const Icon(Icons.inventory_2_outlined)
|
||
: QuantityKindIcon(lot.quantity!.kind, size: 28),
|
||
title: Row(
|
||
children: [
|
||
_LotTypeChip(type: lot.type),
|
||
const SizedBox(width: 8),
|
||
Expanded(child: Text(_lotSubtitle(t, lot))),
|
||
],
|
||
),
|
||
subtitle: lot.storageLocation == null
|
||
? null
|
||
: Text(lot.storageLocation!),
|
||
// 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),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
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!),
|
||
if (lot.presentation != null) presentationLabel(t, lot.presentation!),
|
||
];
|
||
return parts.join(' · ');
|
||
}
|
||
|
||
String _germinationPercent(Translations t, double rate) =>
|
||
t.germination.result(percent: (rate * 100).round());
|
||
|
||
/// 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;
|
||
|
||
@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),
|
||
);
|
||
if (_pickedSpeciesId != null) {
|
||
widget.cubit.linkSpecies(_pickedSpeciesId!);
|
||
}
|
||
Navigator.of(context).pop();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final t = context.t;
|
||
return Padding(
|
||
padding: EdgeInsets.only(
|
||
left: 16,
|
||
right: 16,
|
||
top: 16,
|
||
bottom: MediaQuery.of(context).viewInsets.bottom + 16,
|
||
),
|
||
child: SingleChildScrollView(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
Text(
|
||
t.editVariety.title,
|
||
style: Theme.of(context).textTheme.titleLarge,
|
||
),
|
||
const SizedBox(height: 12),
|
||
TextField(
|
||
key: const Key('editVariety.name'),
|
||
controller: _name,
|
||
decoration: InputDecoration(
|
||
labelText: t.editVariety.name,
|
||
border: const OutlineInputBorder(
|
||
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,
|
||
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: 16),
|
||
Row(
|
||
children: [
|
||
TextButton(
|
||
onPressed: () => Navigator.of(context).pop(),
|
||
child: Text(t.common.cancel),
|
||
),
|
||
const Spacer(),
|
||
FilledButton(
|
||
key: const Key('editVariety.save'),
|
||
onPressed: _save,
|
||
child: Text(t.common.save),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
Future<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;
|
||
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: 16),
|
||
Row(
|
||
children: [
|
||
TextButton(
|
||
onPressed: () => Navigator.of(sheetContext).pop(),
|
||
child: Text(t.common.cancel),
|
||
),
|
||
const Spacer(),
|
||
FilledButton(
|
||
key: const Key('addLot.save'),
|
||
onPressed: () {
|
||
if (editing) {
|
||
cubit.updateLot(
|
||
lotId: existing.id,
|
||
type: selectedType,
|
||
year: selectedYear,
|
||
month: selectedMonth,
|
||
quantity: selectedQuantity,
|
||
presentation: selectedPresentation,
|
||
);
|
||
} else {
|
||
cubit.addLot(
|
||
type: selectedType,
|
||
year: selectedYear,
|
||
month: selectedMonth,
|
||
quantity: selectedQuantity,
|
||
presentation: selectedPresentation,
|
||
);
|
||
}
|
||
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,
|
||
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),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
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;
|
||
return Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
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 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),
|
||
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,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|