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:
parent
11cbdf3022
commit
92fd84590b
10 changed files with 835 additions and 2 deletions
214
apps/app_seeds/lib/ui/lot_history_sheet.dart
Normal file
214
apps/app_seeds/lib/ui/lot_history_sheet.dart
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue