feat(app): show each batch's story as a composite timeline

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).
This commit is contained in:
vjrj 2026-07-18 06:25:55 +02:00
parent 11cbdf3022
commit 92fd84590b
10 changed files with 835 additions and 2 deletions

View file

@ -0,0 +1,214 @@
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),
);
}
}

View file

@ -19,6 +19,7 @@ import '../i18n/strings.g.dart';
import '../state/variety_detail_cubit.dart';
import 'currency_quick_picks.dart';
import 'handover_sheet.dart';
import 'lot_history_sheet.dart';
import 'harvest_date_picker.dart';
import 'photo_pick.dart';
import 'quantity_kind_l10n.dart';
@ -434,6 +435,16 @@ class _LotTile extends StatelessWidget {
children: [
if (lot.type == LotType.seed && lot.latestGerminationRate != null)
_GerminationBadge(rate: lot.latestGerminationRate!),
IconButton(
key: Key('lot.history.${lot.id}'),
icon: const Icon(Icons.history),
tooltip: t.history.tooltip,
onPressed: () => showLotHistorySheet(
context,
repository: context.read<VarietyRepository>(),
lot: lot,
),
),
IconButton(
key: Key('lot.handover.${lot.id}'),
icon: const Icon(Icons.handshake_outlined),