feat(app): ask 'how did it do?' when a harvest is noted (schema v13)

The note growers most wish they had two seasons later — did it do well,
was it worth keeping — never had a home. New append-only GardenOutcomes
table (lotId, season year, three-face rating, free note); the question
appears ONCE, inline, right after recording a harvest in the batch story,
and is skippable without a trace. The answer shows as one more story
line. Rides backups/sync like every mutable table (JSON codec, LWW
import, HLC clock absorb). Migration v12→v13 guarded + verified from
every historical version.
This commit is contained in:
vjrj 2026-07-18 12:18:25 +02:00
parent 92fd84590b
commit bb5b3d4a43
20 changed files with 6030 additions and 15 deletions

View file

@ -38,12 +38,23 @@ class _LotHistorySheetState extends State<_LotHistorySheet> {
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();
@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);
@ -65,6 +76,29 @@ class _LotHistorySheetState extends State<_LotHistorySheet> {
);
setState(() {
_saving = false;
_askOutcome = type == MovementType.harvested;
_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);
});
}
@ -74,7 +108,13 @@ class _LotHistorySheetState extends State<_LotHistorySheet> {
final t = context.t;
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
// 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,
@ -106,6 +146,15 @@ class _LotHistorySheetState extends State<_LotHistorySheet> {
),
],
),
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>>(
@ -134,6 +183,91 @@ class _LotHistorySheetState extends State<_LotHistorySheet> {
}
}
/// 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});
@ -143,6 +277,12 @@ class _HistoryTile extends StatelessWidget {
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,
@ -157,6 +297,12 @@ class _HistoryTile extends StatelessWidget {
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(
@ -194,6 +340,7 @@ class _HistoryTile extends StatelessWidget {
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,