Feedback from the running app: - Remove "handful" from the scale (redundant with spoon); keep the enum value. - Reorder so "packet" (sobre) follows "spoon" (cuchara). - Use the Material Symbols potted-plant for the plant lot type and plant unit (adds material_symbols_icons). - Harvest year is now a dropdown of recent years instead of free text. 38 tests green; Linux build runs with the new icon font bundled.
647 lines
20 KiB
Dart
647 lines
20 KiB
Dart
import 'package:commons_core/commons_core.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_bloc/flutter_bloc.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 '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),
|
|
if (detail.vernacularNames.isNotEmpty) ...[
|
|
const SizedBox(height: 16),
|
|
_SectionTitle(t.detail.names),
|
|
Wrap(
|
|
spacing: 8,
|
|
children: [
|
|
for (final name in detail.vernacularNames)
|
|
Chip(label: Text(name)),
|
|
],
|
|
),
|
|
],
|
|
if (detail.notes != null && detail.notes!.trim().isNotEmpty) ...[
|
|
const SizedBox(height: 16),
|
|
_SectionTitle(t.detail.notes),
|
|
Text(detail.notes!),
|
|
],
|
|
const SizedBox(height: 16),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
_SectionTitle(t.detail.lots),
|
|
TextButton.icon(
|
|
key: const Key('detail.addLot'),
|
|
onPressed: () => _showAddLotSheet(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(
|
|
contentPadding: EdgeInsets.zero,
|
|
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)
|
|
t.detail.year(year: lot.harvestYear!)
|
|
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: 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> _showAddLotSheet(BuildContext context, VarietyDetailCubit cubit) {
|
|
final t = context.t;
|
|
final currentYear = DateTime.now().year;
|
|
// Harvest years: current year down through the last four decades. Newest
|
|
// first because a freshly harvested lot is the common case.
|
|
final years = [for (var y = currentYear; y >= currentYear - 40; y--) y];
|
|
var selectedYear = currentYear;
|
|
var selectedType = LotType.seed;
|
|
Quantity? selectedQuantity;
|
|
return showModalBottomSheet<void>(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
builder: (sheetContext) => StatefulBuilder(
|
|
builder: (sheetContext, setState) => Padding(
|
|
padding: EdgeInsets.only(
|
|
left: 16,
|
|
right: 16,
|
|
top: 16,
|
|
bottom: MediaQuery.of(sheetContext).viewInsets.bottom + 16,
|
|
),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Text(
|
|
t.addLot.title,
|
|
style: Theme.of(sheetContext).textTheme.titleLarge,
|
|
),
|
|
const SizedBox(height: 12),
|
|
LotTypeSelector(
|
|
value: selectedType,
|
|
onChanged: (type) => setState(() {
|
|
selectedType = type;
|
|
selectedQuantity = null;
|
|
}),
|
|
),
|
|
const SizedBox(height: 12),
|
|
DropdownButtonFormField<int>(
|
|
key: const Key('addLot.year'),
|
|
initialValue: selectedYear,
|
|
decoration: InputDecoration(
|
|
labelText: t.addLot.year,
|
|
border: const OutlineInputBorder(),
|
|
),
|
|
items: [
|
|
for (final y in years)
|
|
DropdownMenuItem(value: y, child: Text('$y')),
|
|
],
|
|
onChanged: (value) {
|
|
if (value != null) setState(() => selectedYear = value);
|
|
},
|
|
),
|
|
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: () {
|
|
cubit.addLot(
|
|
type: selectedType,
|
|
year: selectedYear,
|
|
quantity: selectedQuantity,
|
|
);
|
|
Navigator.of(sheetContext).pop();
|
|
},
|
|
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 (detail.photo != null) ...[
|
|
if (hasText) const SizedBox(width: 12),
|
|
ClipRRect(
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: Image.memory(
|
|
detail.photo!,
|
|
width: 140,
|
|
height: 140,
|
|
fit: BoxFit.cover,
|
|
),
|
|
),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
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,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|