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

@ -4,15 +4,19 @@ import 'package:go_router/go_router.dart';
import '../data/variety_repository.dart';
import '../db/enums.dart';
import '../di/injector.dart';
import '../domain/seed_viability.dart';
import '../i18n/strings.g.dart';
import '../services/share_catalog_service.dart';
import '../state/inventory_cubit.dart';
import 'app_drawer.dart';
import 'draft_triage.dart';
import 'quantity_kind_l10n.dart';
import 'quantity_picker.dart';
import 'quick_add_sheet.dart';
import 'seed_glyph.dart';
import 'theme.dart';
import 'variety_detail_screen.dart' show shareStatusLabel;
/// The inventory home: a searchable list of seeds grouped by category, with a
/// quick-add FAB. Driven by [InventoryCubit] over the encrypted DB stream.
@ -26,6 +30,18 @@ class InventoryListScreen extends StatelessWidget {
appBar: AppBar(
title: Text(t.inventory.title),
actions: [
// The paper bridge: only offered once something is marked to share.
BlocSelector<InventoryCubit, InventoryState, bool>(
selector: (state) => state.hasShared,
builder: (context, hasShared) => hasShared
? IconButton(
key: const Key('inventory.printCatalog'),
icon: const Icon(Icons.print_outlined),
tooltip: t.share.printCatalog,
onPressed: () => _printCatalog(context),
)
: const SizedBox.shrink(),
),
IconButton(
key: const Key('inventory.captureBurst'),
icon: const Icon(Icons.add_a_photo_outlined),
@ -80,7 +96,10 @@ class InventoryListScreen extends StatelessWidget {
// Distinguish "no seeds at all" from "filters hid them all".
filtered:
state.categoryFilter.isNotEmpty ||
state.typeFilter.isNotEmpty,
state.typeFilter.isNotEmpty ||
state.organicOnly ||
state.needsReproductionOnly ||
state.sharingOnly,
),
),
],
@ -90,6 +109,46 @@ class InventoryListScreen extends StatelessWidget {
);
}
/// Assembles the localized rows of the "what I share" catalog and saves it
/// as a PDF wherever the user chooses.
Future<void> _printCatalog(BuildContext context) async {
final t = context.t;
final repository = context.read<VarietyRepository>();
final messenger = ScaffoldMessenger.of(context);
final date = DateTime.now();
final dateLabel = MaterialLocalizations.of(context).formatFullDate(date);
final entries = await repository.sharedCatalog();
final rows = [
for (final e in entries)
ShareCatalogRow(
name: e.varietyLabel,
scientificName: e.scientificName,
mode: shareStatusLabel(t, e.offerStatus),
details: _catalogDetails(t, e),
),
];
final saved = await getIt<ShareCatalogService>().saveCatalog(
title: t.share.catalogTitle,
date: dateLabel,
suggestedName:
'tanemaki-catalog-${date.toIso8601String().substring(0, 10)}.pdf',
rows: rows,
);
messenger.showSnackBar(
SnackBar(content: Text(saved ? t.share.catalogSaved : t.share.cancelled)),
);
}
/// The batch facts line of one catalog entry ("Seeds · Year 2024 · 2 cobs").
String? _catalogDetails(Translations t, SharedCatalogEntry e) {
final parts = <String>[
lotTypeLabel(t, e.type),
if (e.harvestYear != null) t.detail.year(year: e.harvestYear!),
if (e.quantity != null) quantityDisplay(t, e.quantity!),
];
return parts.isEmpty ? null : parts.join(' · ');
}
Future<void> _captureBurst(BuildContext context) async {
final t = context.t;
final repository = context.read<VarietyRepository>();
@ -151,14 +210,29 @@ class _FilterBar extends StatelessWidget {
final hasOrganic = state.hasOrganic;
// Only offer the "to regrow" chip when something is flagged for it.
final hasNeedsReproduction = state.hasNeedsReproduction;
// Only offer the "I share" chip when some lot is marked to share.
final hasShared = state.hasShared;
if (categories.isEmpty &&
forms.isEmpty &&
!hasOrganic &&
!hasNeedsReproduction) {
!hasNeedsReproduction &&
!hasShared) {
return const SizedBox.shrink();
}
final chips = <Widget>[
if (hasShared)
FilterChip(
key: const Key('inventory.filter.sharing'),
avatar: Icon(
Icons.volunteer_activism_outlined,
size: 18,
color: state.sharingOnly ? null : seedGreen,
),
label: Text(t.share.filterChip),
selected: state.sharingOnly,
onSelected: (_) => cubit.toggleSharingOnly(),
),
if (hasOrganic)
FilterChip(
key: const Key('inventory.filter.organic'),
@ -202,7 +276,8 @@ class _FilterBar extends StatelessWidget {
state.categoryFilter.isNotEmpty ||
state.typeFilter.isNotEmpty ||
state.organicOnly ||
state.needsReproductionOnly;
state.needsReproductionOnly ||
state.sharingOnly;
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
@ -323,6 +398,19 @@ class _VarietyTile extends StatelessWidget {
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (item.isShared)
Padding(
padding: const EdgeInsetsDirectional.only(end: 4),
child: Tooltip(
key: Key('inventory.shared.${item.id}'),
message: context.t.share.filterChip,
child: const Icon(
Icons.volunteer_activism_outlined,
size: 20,
color: seedGreen,
),
),
),
if (item.isOrganic)
Padding(
padding: const EdgeInsetsDirectional.only(end: 4),

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();