feat(inventory): local sharing — per-lot share terms, filter and printable catalog

The local half of the original Fase 2 (no network, no Offer entity yet):
- Lot sheet gains a 'do you share it?' choice (gift/swap first, sale last,
  never the default); declaring plenty of abundance reveals it with a nudge.
- Lot lines and list tiles badge shared batches; an 'I share' filter and
  a printable PDF catalog (paper bridge for fairs) round out the view.
- Catalog PDF embeds DejaVu (Latin-ext/Cyrillic/Greek); fonts are a
  per-locale resource like the OCR packs.
This commit is contained in:
vjrj 2026-07-09 22:19:55 +02:00
parent e3ec855630
commit ba87bf2719
20 changed files with 982 additions and 36 deletions

View file

@ -179,6 +179,8 @@ String _lotSubtitle(Translations t, VarietyLot lot) {
else if (lot.abundance != null)
abundanceLabel(t, lot.abundance!),
if (lot.presentation != null) presentationLabel(t, lot.presentation!),
if (lot.offerStatus != OfferStatus.private)
shareStatusLabel(t, lot.offerStatus),
];
return parts.join(' · ');
}
@ -885,6 +887,14 @@ String abundanceLabel(Translations t, Abundance a) => switch (a) {
Abundance.runningLow => t.abundance.runningLow,
};
/// Human label for how (whether) a lot is offered to others.
String shareStatusLabel(Translations t, OfferStatus s) => switch (s) {
OfferStatus.private => t.share.private,
OfferStatus.shared => t.share.gift,
OfferStatus.exchange => t.share.exchange,
OfferStatus.sell => t.share.sell,
};
/// Human label for a seed preservation format.
String preservationLabel(Translations t, PreservationFormat p) => switch (p) {
PreservationFormat.jarWithDesiccant => t.preservation.jarWithDesiccant,
@ -930,6 +940,41 @@ class _AbundanceSelector extends StatelessWidget {
}
}
/// Single-select chips for the share terms. Gift and swap come first and sale
/// last, never preselected the gift is first-class, the sale a choice
/// (sharing-model §4.1). There is always a selection ("just for me" default).
class _ShareSelector extends StatelessWidget {
const _ShareSelector({required this.value, required this.onChanged});
final OfferStatus value;
final ValueChanged<OfferStatus> onChanged;
@override
Widget build(BuildContext context) {
final t = context.t;
return Wrap(
spacing: 8,
runSpacing: 4,
children: [
for (final s in const [
OfferStatus.private,
OfferStatus.shared,
OfferStatus.exchange,
OfferStatus.sell,
])
ChoiceChip(
key: Key('share.${s.name}'),
label: Text(shareStatusLabel(t, s)),
selected: value == s,
onSelected: (sel) {
if (sel) onChanged(s);
},
),
],
);
}
}
/// Single-select chips for the preservation format; tapping the current one
/// clears it (null).
class _PreservationSelector extends StatelessWidget {
@ -1113,15 +1158,19 @@ Future<void> _showLotSheet(
final editing = existing != null;
// Provenance + abundance + preservation: hidden behind reveal-on-tap chips so
// the default form stays small (progressive disclosure).
final originNameCtrl = TextEditingController(text: existing?.originName ?? '');
final originNameCtrl = TextEditingController(
text: existing?.originName ?? '',
);
final originPlaceCtrl = TextEditingController(
text: existing?.originPlace ?? '',
);
var selectedAbundance = existing?.abundance;
var selectedPreservation = existing?.preservationFormat;
var selectedShare = existing?.offerStatus ?? OfferStatus.private;
var showOrigin =
existing?.originName != null || existing?.originPlace != null;
var showAbundance = existing?.abundance != null;
var showShare = selectedShare != OfferStatus.private;
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
@ -1227,11 +1276,24 @@ Future<void> _showLotSheet(
if (!showAbundance)
ActionChip(
key: const Key('lot.addAbundance'),
avatar: const Icon(Icons.inventory_2_outlined, size: 18),
avatar: const Icon(
Icons.inventory_2_outlined,
size: 18,
),
label: Text(t.abundance.add),
onPressed: () =>
setState(() => showAbundance = true),
),
if (!showShare)
ActionChip(
key: const Key('lot.addShare'),
avatar: const Icon(
Icons.volunteer_activism_outlined,
size: 18,
),
label: Text(t.share.add),
onPressed: () => setState(() => showShare = true),
),
],
),
if (showOrigin) ...[
@ -1271,9 +1333,41 @@ Future<void> _showLotSheet(
const SizedBox(height: 8),
_AbundanceSelector(
value: selectedAbundance,
onChanged: (a) => setState(() => selectedAbundance = a),
onChanged: (a) => setState(() {
selectedAbundance = a;
// The bridge from "how much" to "do you share it":
// declaring plenty reveals the sharing choice. A
// nudge, never a change of the choice itself.
if (a == Abundance.plentyToShare ||
a == Abundance.enoughToShare) {
showShare = true;
}
}),
),
],
if (showShare) ...[
const SizedBox(height: 16),
Text(
t.share.title,
style: Theme.of(sheetContext).textTheme.labelLarge,
),
const SizedBox(height: 8),
_ShareSelector(
value: selectedShare,
onChanged: (s) => setState(() => selectedShare = s),
),
if (selectedShare == OfferStatus.private &&
(selectedAbundance == Abundance.plentyToShare ||
selectedAbundance == Abundance.enoughToShare))
Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(
t.share.nudge,
key: const Key('share.nudge'),
style: Theme.of(sheetContext).textTheme.bodySmall,
),
),
],
// Layer 2: advanced seed-bank details, collapsed by default.
// Only for seed lots, and (condition checks) an existing lot.
if (selectedType == LotType.seed)
@ -1289,8 +1383,9 @@ Future<void> _showLotSheet(
alignment: Alignment.centerLeft,
child: Text(
t.preservation.title,
style:
Theme.of(sheetContext).textTheme.labelLarge,
style: Theme.of(
sheetContext,
).textTheme.labelLarge,
),
),
const SizedBox(height: 8),
@ -1334,6 +1429,7 @@ Future<void> _showLotSheet(
originPlace: originPlace,
abundance: selectedAbundance,
preservationFormat: selectedPreservation,
offerStatus: selectedShare,
);
} else {
cubit.addLot(
@ -1346,6 +1442,7 @@ Future<void> _showLotSheet(
originPlace: originPlace,
abundance: selectedAbundance,
preservationFormat: selectedPreservation,
offerStatus: selectedShare,
);
}
Navigator.of(sheetContext).pop();