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:
parent
92fd84590b
commit
bb5b3d4a43
20 changed files with 6030 additions and 15 deletions
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue