tane/apps/app_seeds/lib/ui/lot_history_sheet.dart
vjrj 1a826477f2
Some checks failed
ci / test-commons-core (push) Successful in 58s
ci / analyze (push) Successful in 1m49s
site / deploy (push) Successful in 38s
ci / test-app-seeds (push) Has been cancelled
feat(app): hint isolation distance when sowing a cross-pollinating crop
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.
2026-07-18 12:31:42 +02:00

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),
);
}
}