One passive line in the batch story after a 'sown today' tap — 'this species crosses, grow it ~400 m from others' — sourced from the bundled seed-saving guidance (family default + species override). Selfers get nothing: the trap is recording isolation religiously for beans where it never mattered. No input fields, no schema change.
401 lines
14 KiB
Dart
401 lines
14 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:intl/intl.dart';
|
|
|
|
import '../data/variety_repository.dart';
|
|
import '../db/enums.dart';
|
|
import '../domain/seed_saving.dart';
|
|
import '../i18n/strings.g.dart';
|
|
import 'quantity_kind_l10n.dart';
|
|
|
|
/// Opens the batch's story — a read-only timeline of everything already
|
|
/// recorded about it (movements, germination tests, storage checks, and how it
|
|
/// entered the collection), plus two one-tap chips to note "sown today" /
|
|
/// "harvested today". The happy path is literally one tap; there is no form.
|
|
Future<void> showLotHistorySheet(
|
|
BuildContext context, {
|
|
required VarietyRepository repository,
|
|
required VarietyLot lot,
|
|
SeedSavingGuide? guide,
|
|
}) {
|
|
return showModalBottomSheet<void>(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
builder: (_) =>
|
|
_LotHistorySheet(repository: repository, lot: lot, guide: guide),
|
|
);
|
|
}
|
|
|
|
class _LotHistorySheet extends StatefulWidget {
|
|
const _LotHistorySheet({
|
|
required this.repository,
|
|
required this.lot,
|
|
this.guide,
|
|
});
|
|
|
|
final VarietyRepository repository;
|
|
final VarietyLot lot;
|
|
|
|
/// Bundled species guidance, or null. Only read to drop the one-line
|
|
/// isolation hint when a cross-pollinating crop is sown — selfers get
|
|
/// nothing (the fields people record religiously where they don't matter).
|
|
final SeedSavingGuide? guide;
|
|
|
|
@override
|
|
State<_LotHistorySheet> createState() => _LotHistorySheetState();
|
|
}
|
|
|
|
class _LotHistorySheetState extends State<_LotHistorySheet> {
|
|
// One-shot future (data-model rule: a transient sheet must never hold a
|
|
// live subscription). Re-assigned after a quick record to refresh the list.
|
|
late Future<List<LotHistoryEntry>> _history;
|
|
bool _saving = false;
|
|
|
|
// The optional, skippable "how did it do?" asked right after a harvest is
|
|
// noted — the only moment it appears. Dismissed, it never nags again.
|
|
bool _askOutcome = false;
|
|
final _outcomeNote = TextEditingController();
|
|
|
|
// One passive line after sowing a crosser ("this species crosses — isolate
|
|
// ~400 m"). Never shown for selfers, never an input.
|
|
bool _showSowingHint = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_history = widget.repository.lotHistory(widget.lot.id);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_outcomeNote.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _record(MovementType type) async {
|
|
if (_saving) return;
|
|
setState(() => _saving = true);
|
|
final t = context.t;
|
|
final messenger = ScaffoldMessenger.of(context);
|
|
await widget.repository.recordCultivation(
|
|
lotId: widget.lot.id,
|
|
type: type,
|
|
);
|
|
if (!mounted) return;
|
|
messenger.showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
type == MovementType.sown
|
|
? t.history.sownRecorded
|
|
: t.history.harvestRecorded,
|
|
),
|
|
),
|
|
);
|
|
final guide = widget.guide;
|
|
setState(() {
|
|
_saving = false;
|
|
_askOutcome = type == MovementType.harvested;
|
|
_showSowingHint =
|
|
type == MovementType.sown &&
|
|
guide?.pollination == Pollination.cross &&
|
|
guide?.isolationMinM != null;
|
|
_history = widget.repository.lotHistory(widget.lot.id);
|
|
});
|
|
}
|
|
|
|
Future<void> _saveOutcome(GardenOutcomeRating rating) async {
|
|
if (_saving) return;
|
|
setState(() => _saving = true);
|
|
final t = context.t;
|
|
final messenger = ScaffoldMessenger.of(context);
|
|
final note = _outcomeNote.text.trim();
|
|
await widget.repository.addGardenOutcome(
|
|
lotId: widget.lot.id,
|
|
year: DateTime.now().year,
|
|
rating: rating,
|
|
notes: note.isEmpty ? null : note,
|
|
);
|
|
if (!mounted) return;
|
|
messenger.showSnackBar(SnackBar(content: Text(t.history.outcomeSaved)));
|
|
_outcomeNote.clear();
|
|
setState(() {
|
|
_saving = false;
|
|
_askOutcome = false;
|
|
_history = widget.repository.lotHistory(widget.lot.id);
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final t = context.t;
|
|
return SafeArea(
|
|
child: Padding(
|
|
// Bottom inset keeps the optional note field above the keyboard.
|
|
padding: EdgeInsets.fromLTRB(
|
|
16,
|
|
16,
|
|
16,
|
|
24 + MediaQuery.of(context).viewInsets.bottom,
|
|
),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
t.history.title,
|
|
style: Theme.of(context).textTheme.titleLarge,
|
|
),
|
|
const SizedBox(height: 12),
|
|
Wrap(
|
|
spacing: 8,
|
|
children: [
|
|
if (widget.lot.type == LotType.seed)
|
|
ActionChip(
|
|
key: const Key('history.sowToday'),
|
|
avatar: const Icon(Icons.grass_outlined, size: 18),
|
|
label: Text(t.history.sowToday),
|
|
onPressed: _saving
|
|
? null
|
|
: () => _record(MovementType.sown),
|
|
),
|
|
ActionChip(
|
|
key: const Key('history.harvestToday'),
|
|
avatar: const Icon(Icons.agriculture_outlined, size: 18),
|
|
label: Text(t.history.harvestToday),
|
|
onPressed: _saving
|
|
? null
|
|
: () => _record(MovementType.harvested),
|
|
),
|
|
],
|
|
),
|
|
if (_showSowingHint)
|
|
Padding(
|
|
key: const Key('history.isolationHint'),
|
|
padding: const EdgeInsetsDirectional.only(top: 8, start: 4),
|
|
child: Row(
|
|
children: [
|
|
const Icon(Icons.lightbulb_outline, size: 16),
|
|
const SizedBox(width: 6),
|
|
Expanded(
|
|
child: Text(
|
|
context.t.history.isolationHint(
|
|
meters: widget.guide!.isolationMinM!,
|
|
),
|
|
style: Theme.of(context).textTheme.bodySmall,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (_askOutcome) ...[
|
|
const SizedBox(height: 8),
|
|
_OutcomeQuestion(
|
|
note: _outcomeNote,
|
|
saving: _saving,
|
|
onRate: _saveOutcome,
|
|
onDismiss: () => setState(() => _askOutcome = false),
|
|
),
|
|
],
|
|
const SizedBox(height: 8),
|
|
Flexible(
|
|
child: FutureBuilder<List<LotHistoryEntry>>(
|
|
future: _history,
|
|
builder: (context, snapshot) {
|
|
final entries = snapshot.data;
|
|
if (entries == null) {
|
|
return const Padding(
|
|
padding: EdgeInsets.all(24),
|
|
child: Center(child: CircularProgressIndicator()),
|
|
);
|
|
}
|
|
return ListView.builder(
|
|
shrinkWrap: true,
|
|
itemCount: entries.length,
|
|
itemBuilder: (context, i) =>
|
|
_HistoryTile(entry: entries[i]),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The one skippable question of the whole flow: three faces and an optional
|
|
/// note. Tapping a face saves; the X dismisses without a trace.
|
|
class _OutcomeQuestion extends StatelessWidget {
|
|
const _OutcomeQuestion({
|
|
required this.note,
|
|
required this.saving,
|
|
required this.onRate,
|
|
required this.onDismiss,
|
|
});
|
|
|
|
final TextEditingController note;
|
|
final bool saving;
|
|
final ValueChanged<GardenOutcomeRating> onRate;
|
|
final VoidCallback onDismiss;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final t = context.t;
|
|
return Card(
|
|
key: const Key('history.outcomeQuestion'),
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(12, 4, 4, 8),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
t.history.outcomeQuestion,
|
|
style: Theme.of(context).textTheme.titleSmall,
|
|
),
|
|
),
|
|
IconButton(
|
|
key: const Key('history.outcomeDismiss'),
|
|
icon: const Icon(Icons.close, size: 18),
|
|
onPressed: onDismiss,
|
|
),
|
|
],
|
|
),
|
|
Wrap(
|
|
spacing: 8,
|
|
children: [
|
|
ActionChip(
|
|
key: const Key('history.outcomeGood'),
|
|
avatar: const Icon(Icons.sentiment_satisfied_alt, size: 18),
|
|
label: Text(t.history.outcomeGood),
|
|
onPressed: saving
|
|
? null
|
|
: () => onRate(GardenOutcomeRating.good),
|
|
),
|
|
ActionChip(
|
|
key: const Key('history.outcomeMixed'),
|
|
avatar: const Icon(Icons.sentiment_neutral, size: 18),
|
|
label: Text(t.history.outcomeMixed),
|
|
onPressed: saving
|
|
? null
|
|
: () => onRate(GardenOutcomeRating.mixed),
|
|
),
|
|
ActionChip(
|
|
key: const Key('history.outcomePoor'),
|
|
avatar: const Icon(Icons.sentiment_dissatisfied, size: 18),
|
|
label: Text(t.history.outcomePoor),
|
|
onPressed: saving
|
|
? null
|
|
: () => onRate(GardenOutcomeRating.poor),
|
|
),
|
|
],
|
|
),
|
|
TextField(
|
|
key: const Key('history.outcomeNote'),
|
|
controller: note,
|
|
decoration: InputDecoration(
|
|
hintText: t.history.outcomeNoteHint,
|
|
isDense: true,
|
|
border: InputBorder.none,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _HistoryTile extends StatelessWidget {
|
|
const _HistoryTile({required this.entry});
|
|
|
|
final LotHistoryEntry entry;
|
|
|
|
IconData get _icon => switch (entry.kind) {
|
|
LotHistoryKind.created => Icons.spa_outlined,
|
|
LotHistoryKind.germinationTest => Icons.science_outlined,
|
|
LotHistoryKind.conditionCheck => Icons.inventory_2_outlined,
|
|
LotHistoryKind.gardenOutcome => switch (entry.rating) {
|
|
GardenOutcomeRating.good => Icons.sentiment_satisfied_alt,
|
|
GardenOutcomeRating.mixed => Icons.sentiment_neutral,
|
|
GardenOutcomeRating.poor => Icons.sentiment_dissatisfied,
|
|
null => Icons.edit_note,
|
|
},
|
|
LotHistoryKind.movement => switch (entry.movementType!) {
|
|
MovementType.received => Icons.call_received,
|
|
MovementType.given => Icons.redeem_outlined,
|
|
MovementType.sown => Icons.grass_outlined,
|
|
MovementType.harvested => Icons.agriculture_outlined,
|
|
MovementType.germinationTest => Icons.science_outlined,
|
|
MovementType.split => Icons.call_split,
|
|
MovementType.discarded => Icons.delete_outline,
|
|
},
|
|
};
|
|
|
|
String _title(Translations t) => switch (entry.kind) {
|
|
LotHistoryKind.created => t.history.created,
|
|
LotHistoryKind.conditionCheck => t.conditionCheck.title,
|
|
LotHistoryKind.gardenOutcome => switch (entry.rating) {
|
|
GardenOutcomeRating.good => t.history.ratedGood,
|
|
GardenOutcomeRating.mixed => t.history.ratedMixed,
|
|
GardenOutcomeRating.poor => t.history.ratedPoor,
|
|
null => t.history.outcomeTitle,
|
|
},
|
|
LotHistoryKind.germinationTest => entry.germinationRate == null
|
|
? t.germination.title
|
|
: t.history.germinationResult(
|
|
percent: (entry.germinationRate! * 100).round(),
|
|
),
|
|
LotHistoryKind.movement => switch (entry.movementType!) {
|
|
MovementType.received => t.history.movementReceived,
|
|
MovementType.given => t.history.movementGiven,
|
|
MovementType.sown => t.history.movementSown,
|
|
MovementType.harvested => t.history.movementHarvested,
|
|
MovementType.germinationTest => t.history.movementGerminationTest,
|
|
MovementType.split => t.history.movementSplit,
|
|
MovementType.discarded => t.history.movementDiscarded,
|
|
},
|
|
};
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final t = context.t;
|
|
final theme = Theme.of(context);
|
|
final when = entry.when;
|
|
final date = when == null
|
|
? null
|
|
// Format via the Localizations locale, not the raw app locale (see
|
|
// backup_section.dart: `ast` has no intl symbols, mapped to `es`).
|
|
: DateFormat.yMMMd(
|
|
Localizations.localeOf(context).languageCode,
|
|
).format(DateTime.fromMillisecondsSinceEpoch(when));
|
|
|
|
final origin = [
|
|
entry.originName,
|
|
entry.originPlace,
|
|
].whereType<String>().where((s) => s.trim().isNotEmpty).join(' · ');
|
|
|
|
final details = [
|
|
if (entry.kind == LotHistoryKind.created && origin.isNotEmpty)
|
|
t.history.from(origin: origin),
|
|
if (entry.year case final year?) t.history.outcomeYear(year: year),
|
|
if (entry.quantity != null) quantityDisplay(t, entry.quantity!),
|
|
?entry.counterpartyName,
|
|
if (entry.hasParentLink) t.history.linkedEarlier,
|
|
if (entry.notes case final n? when n.trim().isNotEmpty) n.trim(),
|
|
].join('\n');
|
|
|
|
return ListTile(
|
|
dense: true,
|
|
contentPadding: EdgeInsets.zero,
|
|
leading: Icon(_icon, size: 20, color: theme.colorScheme.primary),
|
|
title: Text(_title(t)),
|
|
subtitle: details.isEmpty ? null : Text(details),
|
|
trailing: date == null
|
|
? null
|
|
: Text(date, style: theme.textTheme.bodySmall),
|
|
);
|
|
}
|
|
}
|