The Movement log (history + provenance DAG) was recorded but invisible. A new history sheet on every lot tile narrates the batch: movements, germination tests, storage checks and how it entered the collection (origin attached), most-recent first. Two one-tap chips record 'sown today' / 'harvested today' — the first UI door to the sown/harvested movement types. No schema change; one-shot reads only (no live subscriptions in a transient sheet).
214 lines
7.2 KiB
Dart
214 lines
7.2 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:intl/intl.dart';
|
|
|
|
import '../data/variety_repository.dart';
|
|
import '../db/enums.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,
|
|
}) {
|
|
return showModalBottomSheet<void>(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
builder: (_) => _LotHistorySheet(repository: repository, lot: lot),
|
|
);
|
|
}
|
|
|
|
class _LotHistorySheet extends StatefulWidget {
|
|
const _LotHistorySheet({required this.repository, required this.lot});
|
|
|
|
final VarietyRepository repository;
|
|
final VarietyLot lot;
|
|
|
|
@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;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_history = widget.repository.lotHistory(widget.lot.id);
|
|
}
|
|
|
|
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,
|
|
),
|
|
),
|
|
);
|
|
setState(() {
|
|
_saving = false;
|
|
_history = widget.repository.lotHistory(widget.lot.id);
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final t = context.t;
|
|
return SafeArea(
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
|
|
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),
|
|
),
|
|
],
|
|
),
|
|
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]),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
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.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.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.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),
|
|
);
|
|
}
|
|
}
|