tane/apps/app_seeds/lib/ui/variety_detail_screen.dart
vjrj 42c16c0e3f feat(ui): Material 3 redesign, cover-photo viewer, About screen
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.
2026-07-09 11:51:59 +02:00

1223 lines
39 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:typed_data';
import 'package: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,
),
),
);
}
}