Add Wikipedia/forum-style URLs to a variety: - Repository: addExternalLink/removeExternalLink (soft delete); watchVariety loads links and re-emits on external_links changes; ExternalLinkItem model (title preferred over URL for display). - Cubit: addExternalLink/removeExternalLink. - Detail: a "Links" section with tappable link chips (open via url_launcher, http(s):// prepended if missing), delete per chip, and an "Add link" dialog (URL + optional title). Tests: repo add/remove + widget add/remove. 52 green; Linux runs.
961 lines
29 KiB
Dart
961 lines
29 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||
import 'package:image_picker/image_picker.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 '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: Text(_lotSubtitle(t, lot)),
|
||
subtitle: lot.storageLocation == null
|
||
? null
|
||
: Text(lot.storageLocation!),
|
||
trailing: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
if (lot.latestGerminationRate != null)
|
||
_GerminationBadge(rate: lot.latestGerminationRate!),
|
||
IconButton(
|
||
key: Key('lot.germination.${lot.id}'),
|
||
icon: const Icon(Icons.grass_outlined),
|
||
tooltip: t.germination.title,
|
||
onPressed: () =>
|
||
_showGerminationSheet(context, cubit, lot),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
String _lotSubtitle(Translations t, VarietyLot lot) {
|
||
final parts = <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!),
|
||
];
|
||
return parts.join(' · ');
|
||
}
|
||
|
||
String _germinationPercent(Translations t, double rate) =>
|
||
t.germination.result(percent: (rate * 100).round());
|
||
|
||
/// A compact pill showing the latest germination rate on a lot tile.
|
||
class _GerminationBadge extends StatelessWidget {
|
||
const _GerminationBadge({required this.rate});
|
||
|
||
final double rate;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final scheme = Theme.of(context).colorScheme;
|
||
return Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||
decoration: BoxDecoration(
|
||
color: scheme.secondaryContainer,
|
||
borderRadius: BorderRadius.circular(12),
|
||
),
|
||
child: Text(
|
||
_germinationPercent(context.t, rate),
|
||
style: TextStyle(
|
||
color: scheme.onSecondaryContainer,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
Future<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(),
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: TextField(
|
||
key: const Key('germination.sampleSize'),
|
||
controller: sample,
|
||
keyboardType: TextInputType.number,
|
||
decoration: InputDecoration(
|
||
labelText: t.germination.sampleSize,
|
||
border: const OutlineInputBorder(),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 16),
|
||
Row(
|
||
children: [
|
||
TextButton(
|
||
onPressed: () => Navigator.of(sheetContext).pop(),
|
||
child: Text(t.common.cancel),
|
||
),
|
||
const Spacer(),
|
||
FilledButton(
|
||
key: const Key('germination.save'),
|
||
onPressed: () {
|
||
cubit.addGerminationTest(
|
||
lotId: lot.id,
|
||
testedOn: DateTime.now().millisecondsSinceEpoch,
|
||
sampleSize: int.tryParse(sample.text.trim()),
|
||
germinatedCount: int.tryParse(germinated.text.trim()),
|
||
);
|
||
Navigator.of(sheetContext).pop();
|
||
},
|
||
child: Text(t.germination.add),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Future<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(),
|
||
),
|
||
),
|
||
const SizedBox(height: 12),
|
||
TextField(
|
||
key: const Key('editVariety.species'),
|
||
controller: _speciesField,
|
||
decoration: InputDecoration(
|
||
labelText: t.editVariety.species,
|
||
hintText: t.editVariety.speciesHint,
|
||
prefixIcon: const Icon(Icons.eco_outlined),
|
||
border: const OutlineInputBorder(),
|
||
),
|
||
onChanged: _onSpeciesQuery,
|
||
),
|
||
for (final match in _suggestions)
|
||
ListTile(
|
||
dense: true,
|
||
key: Key('species.option.${match.id}'),
|
||
title: Text(match.displayLabel),
|
||
subtitle: match.family == null ? null : Text(match.family!),
|
||
onTap: () => _pick(match),
|
||
),
|
||
const SizedBox(height: 12),
|
||
TextField(
|
||
controller: _category,
|
||
decoration: InputDecoration(
|
||
labelText: t.editVariety.category,
|
||
border: const OutlineInputBorder(),
|
||
),
|
||
),
|
||
const SizedBox(height: 12),
|
||
TextField(
|
||
controller: _notes,
|
||
minLines: 2,
|
||
maxLines: 5,
|
||
decoration: InputDecoration(
|
||
labelText: t.editVariety.notes,
|
||
border: const OutlineInputBorder(),
|
||
),
|
||
),
|
||
const SizedBox(height: 16),
|
||
Row(
|
||
children: [
|
||
TextButton(
|
||
onPressed: () => Navigator.of(context).pop(),
|
||
child: Text(t.common.cancel),
|
||
),
|
||
const Spacer(),
|
||
FilledButton(
|
||
key: const Key('editVariety.save'),
|
||
onPressed: _save,
|
||
child: Text(t.common.save),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
Future<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;
|
||
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: [
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: Text(
|
||
editing ? t.detail.editLot : t.addLot.title,
|
||
style: Theme.of(sheetContext).textTheme.titleLarge,
|
||
),
|
||
),
|
||
if (editing)
|
||
IconButton(
|
||
key: const Key('lot.delete'),
|
||
icon: const Icon(Icons.delete_outline),
|
||
tooltip: t.common.delete,
|
||
onPressed: () {
|
||
cubit.deleteLot(existing.id);
|
||
Navigator.of(sheetContext).pop();
|
||
},
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 12),
|
||
LotTypeSelector(
|
||
value: selectedType,
|
||
onChanged: (type) => setState(() {
|
||
selectedType = type;
|
||
selectedQuantity = null;
|
||
}),
|
||
),
|
||
const SizedBox(height: 12),
|
||
Text(
|
||
t.addLot.year,
|
||
style: Theme.of(sheetContext).textTheme.labelLarge,
|
||
),
|
||
const SizedBox(height: 8),
|
||
HarvestDateField(
|
||
year: selectedYear,
|
||
month: selectedMonth,
|
||
onChanged: (year, month) => setState(() {
|
||
selectedYear = year;
|
||
selectedMonth = month;
|
||
}),
|
||
),
|
||
const SizedBox(height: 16),
|
||
Text(
|
||
t.addLot.quantity,
|
||
style: Theme.of(sheetContext).textTheme.labelLarge,
|
||
),
|
||
const SizedBox(height: 8),
|
||
QuantityPicker(
|
||
type: selectedType,
|
||
value: selectedQuantity,
|
||
onChanged: (q) => selectedQuantity = q,
|
||
),
|
||
const SizedBox(height: 16),
|
||
Row(
|
||
children: [
|
||
TextButton(
|
||
onPressed: () => Navigator.of(sheetContext).pop(),
|
||
child: Text(t.common.cancel),
|
||
),
|
||
const Spacer(),
|
||
FilledButton(
|
||
key: const Key('addLot.save'),
|
||
onPressed: () {
|
||
if (editing) {
|
||
cubit.updateLot(
|
||
lotId: existing.id,
|
||
type: selectedType,
|
||
year: selectedYear,
|
||
month: selectedMonth,
|
||
quantity: selectedQuantity,
|
||
);
|
||
} else {
|
||
cubit.addLot(
|
||
type: selectedType,
|
||
year: selectedYear,
|
||
month: selectedMonth,
|
||
quantity: selectedQuantity,
|
||
);
|
||
}
|
||
Navigator.of(sheetContext).pop();
|
||
},
|
||
child: Text(t.common.save),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Future<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)
|
||
Text(
|
||
detail.category!,
|
||
style: const TextStyle(
|
||
color: seedLink,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
if (detail.scientificName != null) ...[
|
||
const SizedBox(height: 2),
|
||
Text(
|
||
detail.scientificName!,
|
||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||
fontStyle: FontStyle.italic,
|
||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
if (hasText) const SizedBox(width: 12),
|
||
_PhotoGallery(detail: detail),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
/// A 140×140 photo carousel (mockup 07): swipe between photos, page dots, add a
|
||
/// photo, and delete the current one. Shows just an "add" button when empty.
|
||
class _PhotoGallery extends StatefulWidget {
|
||
const _PhotoGallery({required this.detail});
|
||
|
||
final VarietyDetail detail;
|
||
|
||
@override
|
||
State<_PhotoGallery> createState() => _PhotoGalleryState();
|
||
}
|
||
|
||
class _PhotoGalleryState extends State<_PhotoGallery> {
|
||
final _controller = PageController();
|
||
int _page = 0;
|
||
|
||
@override
|
||
void dispose() {
|
||
_controller.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
Future<void> _addPhoto(VarietyDetailCubit cubit) async {
|
||
final file = await ImagePicker().pickImage(
|
||
source: ImageSource.gallery,
|
||
maxWidth: 1280,
|
||
imageQuality: 80,
|
||
);
|
||
final bytes = await file?.readAsBytes();
|
||
if (bytes != null) await cubit.addPhoto(bytes);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final cubit = context.read<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: [
|
||
SizedBox(
|
||
height: 140,
|
||
child: Stack(
|
||
children: [
|
||
ClipRRect(
|
||
borderRadius: BorderRadius.circular(12),
|
||
child: PageView.builder(
|
||
controller: _controller,
|
||
itemCount: photos.length,
|
||
onPageChanged: (i) => setState(() => _page = i),
|
||
itemBuilder: (_, i) => Image.memory(
|
||
photos[i].bytes,
|
||
width: 140,
|
||
height: 140,
|
||
fit: BoxFit.cover,
|
||
),
|
||
),
|
||
),
|
||
Positioned(
|
||
top: 2,
|
||
right: 2,
|
||
child: _CircleIconButton(
|
||
icon: Icons.close,
|
||
tooltip: context.t.common.delete,
|
||
onPressed: () => cubit.removePhoto(photos[page].id),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(height: 6),
|
||
if (photos.length > 1) _Dots(count: photos.length, active: page),
|
||
TextButton.icon(
|
||
key: const Key('photo.add'),
|
||
onPressed: () => _addPhoto(cubit),
|
||
icon: const Icon(Icons.add_a_photo_outlined, size: 18),
|
||
label: Text(context.t.quickAdd.addPhoto),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _CircleIconButton extends StatelessWidget {
|
||
const _CircleIconButton({
|
||
required this.icon,
|
||
required this.onPressed,
|
||
this.tooltip,
|
||
});
|
||
|
||
final IconData icon;
|
||
final VoidCallback onPressed;
|
||
final String? tooltip;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Material(
|
||
color: Colors.black45,
|
||
shape: const CircleBorder(),
|
||
child: IconButton(
|
||
key: const Key('photo.delete'),
|
||
icon: Icon(icon, size: 18, color: Colors.white),
|
||
tooltip: tooltip,
|
||
visualDensity: VisualDensity.compact,
|
||
onPressed: onPressed,
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _Dots extends StatelessWidget {
|
||
const _Dots({required this.count, required this.active});
|
||
|
||
final int count;
|
||
final int active;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Row(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
for (var i = 0; i < count; i++)
|
||
Container(
|
||
width: 6,
|
||
height: 6,
|
||
margin: const EdgeInsets.symmetric(horizontal: 2),
|
||
decoration: BoxDecoration(
|
||
shape: BoxShape.circle,
|
||
color: i == active ? seedGreen : Colors.black26,
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
class _SectionTitle extends StatelessWidget {
|
||
const _SectionTitle(this.text);
|
||
|
||
final String text;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Padding(
|
||
padding: const EdgeInsets.only(bottom: 8),
|
||
child: Text(
|
||
text,
|
||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||
color: Theme.of(context).colorScheme.primary,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|