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

Binary file not shown.

Binary file not shown.

View file

@ -22,6 +22,7 @@ class VarietyListItem extends Equatable {
this.isDraft = false,
this.isOrganic = false,
this.needsReproduction = false,
this.isShared = false,
this.viability = SeedViability.unknown,
});
@ -50,6 +51,10 @@ class VarietyListItem extends Equatable {
/// filterable, alongside the automatic viability warning.
final bool needsReproduction;
/// True when at least one lot is marked to give away, swap or sell the
/// "what I share" view filters on this.
final bool isShared;
/// The most-urgent viability status across this variety's seed lots
/// (expired ranks above expiringSoon), so the list can flag what to sow or
/// reproduce before it lapses. [SeedViability.unknown] when nothing is aging
@ -67,10 +72,50 @@ class VarietyListItem extends Equatable {
isDraft,
isOrganic,
needsReproduction,
isShared,
viability,
];
}
/// One shared lot for the printable catalog: the variety it belongs to plus
/// the batch facts a fair-goer needs (what, from when, how much, on what
/// terms). Built by [VarietyRepository.sharedCatalog].
class SharedCatalogEntry extends Equatable {
const SharedCatalogEntry({
required this.varietyLabel,
required this.offerStatus,
this.scientificName,
this.category,
this.type = LotType.seed,
this.harvestYear,
this.quantity,
this.abundance,
});
final String varietyLabel;
final String? scientificName;
final String? category;
final LotType type;
final int? harvestYear;
final Quantity? quantity;
final Abundance? abundance;
/// Never [OfferStatus.private] private lots don't enter the catalog.
final OfferStatus offerStatus;
@override
List<Object?> get props => [
varietyLabel,
scientificName,
category,
type,
harvestYear,
quantity,
abundance,
offerStatus,
];
}
/// One germination test on a lot; [rate] is derived (0..1), null when it can't
/// be computed.
class GerminationEntry extends Equatable {
@ -141,6 +186,7 @@ class VarietyLot extends Equatable {
this.originPlace,
this.abundance,
this.preservationFormat,
this.offerStatus = OfferStatus.private,
this.germinationTests = const [],
this.conditionChecks = const [],
});
@ -168,6 +214,11 @@ class VarietyLot extends Equatable {
/// How the (seed) lot is physically conserved; distinct from storage location.
final PreservationFormat? preservationFormat;
/// Whether (and how) this batch is offered to others: kept private, to give
/// away, to swap, or for sale. Local-only for now publishing an offer to
/// the network is Block 2.
final OfferStatus offerStatus;
final List<GerminationEntry> germinationTests;
/// Storage-condition checks, most-recent first (`conditionChecks.first` = last).
@ -190,6 +241,7 @@ class VarietyLot extends Equatable {
originPlace,
abundance,
preservationFormat,
offerStatus,
germinationTests,
conditionChecks,
];
@ -390,7 +442,9 @@ class VarietyRepository {
final rows =
await (_db.select(_db.varieties)
// Drafts live in the "to catalogue" tray, not the main list.
..where((v) => v.isDeleted.equals(false) & v.isDraft.equals(false))
..where(
(v) => v.isDeleted.equals(false) & v.isDraft.equals(false),
)
..orderBy([
(v) => OrderingTerm(expression: v.category),
(v) => OrderingTerm(expression: v.label),
@ -404,9 +458,9 @@ class VarietyRepository {
/// photo changes.
Stream<List<VarietyListItem>> watchDrafts() {
final triggers = StreamGroup.merge<void>([
(_db.select(_db.varieties)..where((v) => v.isDraft.equals(true)))
.watch()
.map((_) {}),
(_db.select(
_db.varieties,
)..where((v) => v.isDraft.equals(true))).watch().map((_) {}),
_db.select(_db.attachments).watch().map((_) {}),
]);
return triggers.asyncMap((_) => _loadDrafts());
@ -415,9 +469,7 @@ class VarietyRepository {
Future<List<VarietyListItem>> _loadDrafts() async {
final rows =
await (_db.select(_db.varieties)
..where(
(v) => v.isDeleted.equals(false) & v.isDraft.equals(true),
)
..where((v) => v.isDeleted.equals(false) & v.isDraft.equals(true))
// Packed HLC sorts monotonically, so newest capture is first even
// when two are stamped within the same wall-clock millisecond.
..orderBy([(v) => OrderingTerm.desc(v.updatedAt)]))
@ -432,6 +484,7 @@ class VarietyRepository {
final sciNames = await _scientificNamesFor(speciesIds);
final speciesViability = await _speciesViabilityFor(speciesIds);
final lotTypes = await _lotTypesFor(varietyIds);
final shared = await _sharedVarietyIdsFor(varietyIds);
final seedYears = await _seedHarvestYearsFor(varietyIds);
final currentYear = DateTime.fromMillisecondsSinceEpoch(_now()).year;
return rows
@ -446,6 +499,7 @@ class VarietyRepository {
isDraft: v.isDraft,
isOrganic: v.isOrganic,
needsReproduction: v.needsReproduction,
isShared: shared.contains(v.id),
viability: _worstViability(
harvestYears: seedYears[v.id] ?? const [],
viabilityYears: v.speciesId == null
@ -537,6 +591,21 @@ class VarietyRepository {
return byVariety;
}
/// The subset of [varietyIds] holding at least one lot offered to others
/// (to give away, swap or sell) one query.
Future<Set<String>> _sharedVarietyIdsFor(List<String> varietyIds) async {
if (varietyIds.isEmpty) return const {};
final rows =
await (_db.select(_db.lots)..where(
(l) =>
l.varietyId.isIn(varietyIds) &
l.isDeleted.equals(false) &
l.offerStatus.equalsValue(OfferStatus.private).not(),
))
.get();
return rows.map((l) => l.varietyId).toSet();
}
/// Loads the first photo BLOB for each of [varietyIds] (one query).
Future<Map<String, Uint8List>> _firstPhotosFor(
List<String> varietyIds,
@ -942,6 +1011,7 @@ class VarietyRepository {
String? originPlace,
Abundance? abundance,
PreservationFormat? preservationFormat,
OfferStatus offerStatus = OfferStatus.private,
}) async {
final (created, updated) = _stamp();
final id = idGen.newId();
@ -966,6 +1036,7 @@ class VarietyRepository {
originPlace: Value(originPlace),
abundance: Value(abundance),
preservationFormat: Value(preservationFormat),
offerStatus: Value(offerStatus),
),
);
return id;
@ -985,6 +1056,7 @@ class VarietyRepository {
String? originPlace,
Abundance? abundance,
PreservationFormat? preservationFormat,
OfferStatus offerStatus = OfferStatus.private,
}) async {
final (_, updated) = _stamp();
await (_db.update(_db.lots)..where((l) => l.id.equals(lotId))).write(
@ -1001,12 +1073,70 @@ class VarietyRepository {
originPlace: Value(originPlace),
abundance: Value(abundance),
preservationFormat: Value(preservationFormat),
offerStatus: Value(offerStatus),
updatedAt: Value(updated),
lastAuthor: Value(nodeId),
),
);
}
/// Every lot offered to others (to give away, swap or sell), joined with its
/// variety and ordered by label then harvest year the content of the
/// printable "what I share" catalog.
Future<List<SharedCatalogEntry>> sharedCatalog() async {
final lots =
await (_db.select(_db.lots)..where(
(l) =>
l.isDeleted.equals(false) &
l.offerStatus.equalsValue(OfferStatus.private).not(),
))
.get();
if (lots.isEmpty) return const [];
final varietyIds = lots.map((l) => l.varietyId).toSet();
final varieties =
await (_db.select(_db.varieties)..where(
(v) =>
v.id.isIn(varietyIds) &
v.isDeleted.equals(false) &
v.isDraft.equals(false),
))
.get();
final byId = {for (final v in varieties) v.id: v};
final sciNames = await _scientificNamesFor(
varieties.map((v) => v.speciesId).whereType<String>().toSet(),
);
final entries = <SharedCatalogEntry>[
for (final l in lots)
if (byId[l.varietyId] case final v?)
SharedCatalogEntry(
varietyLabel: v.label,
scientificName: v.speciesId == null ? null : sciNames[v.speciesId],
category: v.category,
type: l.type,
harvestYear: l.harvestYear,
quantity:
l.quantityKind != null ||
l.quantityPrecise != null ||
l.quantityLabel != null
? Quantity(
kind: _parseKind(l.quantityKind),
count: l.quantityPrecise,
label: l.quantityLabel,
)
: null,
abundance: l.abundance,
offerStatus: l.offerStatus,
),
];
entries.sort(
(a, b) =>
a.varietyLabel.toLowerCase().compareTo(b.varietyLabel.toLowerCase()),
);
return entries;
}
/// Soft-deletes a lot (tombstone); it leaves the variety's lot list.
Future<void> softDeleteLot(String lotId) async {
final (_, updated) = _stamp();
@ -1487,10 +1617,11 @@ class VarietyRepository {
Future<String?> _resolveSpeciesByName(String? scientificName) async {
final name = scientificName?.trim();
if (name == null || name.isEmpty) return null;
final local = await (_db.select(_db.species)..where(
(s) => s.scientificName.equals(name) & s.isDeleted.equals(false),
))
.getSingleOrNull();
final local =
await (_db.select(_db.species)..where(
(s) => s.scientificName.equals(name) & s.isDeleted.equals(false),
))
.getSingleOrNull();
return local?.id;
}
@ -1501,12 +1632,11 @@ class VarietyRepository {
final resolved = <String, String?>{};
for (final entry in speciesNamesById.entries) {
final local =
await (_db.select(_db.species)
..where(
(s) =>
s.scientificName.equals(entry.value) &
s.isDeleted.equals(false),
))
await (_db.select(_db.species)..where(
(s) =>
s.scientificName.equals(entry.value) &
s.isDeleted.equals(false),
))
.getSingleOrNull();
resolved[entry.key] = local?.id;
}
@ -1529,7 +1659,9 @@ class VarietyRepository {
final existing = await (_db.select(
table,
)..where((_) => idColumn.isIn(byId.keys))).get();
final existingUpdatedAt = {for (final r in existing) idOf(r): updatedAtOf(r)};
final existingUpdatedAt = {
for (final r in existing) idOf(r): updatedAtOf(r),
};
var inserted = 0, updated = 0, skipped = 0;
for (final row in byId.values) {
@ -1548,7 +1680,11 @@ class VarietyRepository {
skipped++;
}
}
return ImportSummary(inserted: inserted, updated: updated, skipped: skipped);
return ImportSummary(
inserted: inserted,
updated: updated,
skipped: skipped,
);
}
/// Movements are append-only: insert unknown ids, never touch known ones.
@ -1598,6 +1734,7 @@ class VarietyRepository {
originPlace: l.originPlace,
abundance: l.abundance,
preservationFormat: l.preservationFormat,
offerStatus: l.offerStatus,
germinationTests: germinationTests,
conditionChecks: conditionChecks,
quantity: hasQuantity

View file

@ -22,6 +22,7 @@ import '../services/ocr/label_text_extractor.dart';
import '../services/ocr/ocr_language.dart';
import '../services/ocr/tesseract_label_extractor.dart';
import '../services/onboarding_store.dart';
import '../services/share_catalog_service.dart';
/// The app's service locator. Kept to the composition root — widgets get their
/// repositories from here (or via BlocProvider), never by reaching into it deep
@ -75,6 +76,9 @@ Future<void> configureDependencies() async {
..registerSingleton<OnboardingStore>(OnboardingStore(secretStore))
..registerSingleton<ExportImportService>(
ExportImportService(repository: varietyRepository, files: fileService),
)
..registerSingleton<ShareCatalogService>(
ShareCatalogService(files: fileService),
);
}

View file

@ -229,6 +229,20 @@
"enoughForMe": "Enough for me",
"runningLow": "Running low"
},
"share": {
"add": "Do you share it?",
"title": "Do you share it?",
"nudge": "You have plenty — you could share some.",
"private": "Just for me",
"gift": "To give away",
"exchange": "To swap",
"sell": "For sale",
"filterChip": "I share",
"printCatalog": "Print what I share",
"catalogTitle": "What I share",
"catalogSaved": "Catalog saved",
"cancelled": "Cancelled"
},
"cropCalendar": {
"add": "Crop calendar",
"title": "Crop calendar",

View file

@ -229,6 +229,20 @@
"enoughForMe": "Suficiente para mí",
"runningLow": "Queda poca"
},
"share": {
"add": "¿La compartes?",
"title": "¿La compartes?",
"nudge": "Tienes de sobra: podrías compartir un poco.",
"private": "Solo para mí",
"gift": "Para regalar",
"exchange": "Para intercambiar",
"sell": "En venta",
"filterChip": "Comparto",
"printCatalog": "Imprimir lo que comparto",
"catalogTitle": "Lo que comparto",
"catalogSaved": "Catálogo guardado",
"cancelled": "Cancelado"
},
"cropCalendar": {
"add": "Calendario de cultivo",
"title": "Calendario de cultivo",

View file

@ -4,9 +4,9 @@
/// To regenerate, run: `dart run slang`
///
/// Locales: 2
/// Strings: 530 (265 per locale)
/// Strings: 554 (277 per locale)
///
/// Built on 2026-07-09 at 17:18 UTC
/// Built on 2026-07-09 at 20:14 UTC
// coverage:ignore-file
// ignore_for_file: type=lint, unused_import

View file

@ -62,6 +62,7 @@ class Translations with BaseTranslations<AppLocale, Translations> {
late final Translations$presentation$en presentation = Translations$presentation$en.internal(_root);
late final Translations$provenance$en provenance = Translations$provenance$en.internal(_root);
late final Translations$abundance$en abundance = Translations$abundance$en.internal(_root);
late final Translations$share$en share = Translations$share$en.internal(_root);
late final Translations$cropCalendar$en cropCalendar = Translations$cropCalendar$en.internal(_root);
late final Translations$needsReproduction$en needsReproduction = Translations$needsReproduction$en.internal(_root);
late final Translations$preservation$en preservation = Translations$preservation$en.internal(_root);
@ -765,6 +766,51 @@ class Translations$abundance$en {
String get runningLow => 'Running low';
}
// Path: share
class Translations$share$en {
Translations$share$en.internal(this._root);
final Translations _root; // ignore: unused_field
// Translations
/// en: 'Do you share it?'
String get add => 'Do you share it?';
/// en: 'Do you share it?'
String get title => 'Do you share it?';
/// en: 'You have plenty — you could share some.'
String get nudge => 'You have plenty — you could share some.';
/// en: 'Just for me'
String get private => 'Just for me';
/// en: 'To give away'
String get gift => 'To give away';
/// en: 'To swap'
String get exchange => 'To swap';
/// en: 'For sale'
String get sell => 'For sale';
/// en: 'I share'
String get filterChip => 'I share';
/// en: 'Print what I share'
String get printCatalog => 'Print what I share';
/// en: 'What I share'
String get catalogTitle => 'What I share';
/// en: 'Catalog saved'
String get catalogSaved => 'Catalog saved';
/// en: 'Cancelled'
String get cancelled => 'Cancelled';
}
// Path: cropCalendar
class Translations$cropCalendar$en {
Translations$cropCalendar$en.internal(this._root);
@ -1588,6 +1634,18 @@ extension on Translations {
'abundance.enoughToShare' => 'Enough to share a little',
'abundance.enoughForMe' => 'Enough for me',
'abundance.runningLow' => 'Running low',
'share.add' => 'Do you share it?',
'share.title' => 'Do you share it?',
'share.nudge' => 'You have plenty — you could share some.',
'share.private' => 'Just for me',
'share.gift' => 'To give away',
'share.exchange' => 'To swap',
'share.sell' => 'For sale',
'share.filterChip' => 'I share',
'share.printCatalog' => 'Print what I share',
'share.catalogTitle' => 'What I share',
'share.catalogSaved' => 'Catalog saved',
'share.cancelled' => 'Cancelled',
'cropCalendar.add' => 'Crop calendar',
'cropCalendar.title' => 'Crop calendar',
'cropCalendar.sow' => 'Sow',

View file

@ -61,6 +61,7 @@ class TranslationsEs extends Translations with BaseTranslations<AppLocale, Trans
@override late final _Translations$presentation$es presentation = _Translations$presentation$es._(_root);
@override late final _Translations$provenance$es provenance = _Translations$provenance$es._(_root);
@override late final _Translations$abundance$es abundance = _Translations$abundance$es._(_root);
@override late final _Translations$share$es share = _Translations$share$es._(_root);
@override late final _Translations$cropCalendar$es cropCalendar = _Translations$cropCalendar$es._(_root);
@override late final _Translations$needsReproduction$es needsReproduction = _Translations$needsReproduction$es._(_root);
@override late final _Translations$preservation$es preservation = _Translations$preservation$es._(_root);
@ -442,6 +443,27 @@ class _Translations$abundance$es extends Translations$abundance$en {
@override String get runningLow => 'Queda poca';
}
// Path: share
class _Translations$share$es extends Translations$share$en {
_Translations$share$es._(TranslationsEs root) : this._root = root, super.internal(root);
final TranslationsEs _root; // ignore: unused_field
// Translations
@override String get add => '¿La compartes?';
@override String get title => '¿La compartes?';
@override String get nudge => 'Tienes de sobra: podrías compartir un poco.';
@override String get private => 'Solo para mí';
@override String get gift => 'Para regalar';
@override String get exchange => 'Para intercambiar';
@override String get sell => 'En venta';
@override String get filterChip => 'Comparto';
@override String get printCatalog => 'Imprimir lo que comparto';
@override String get catalogTitle => 'Lo que comparto';
@override String get catalogSaved => 'Catálogo guardado';
@override String get cancelled => 'Cancelado';
}
// Path: cropCalendar
class _Translations$cropCalendar$es extends Translations$cropCalendar$en {
_Translations$cropCalendar$es._(TranslationsEs root) : this._root = root, super.internal(root);
@ -1078,6 +1100,18 @@ extension on TranslationsEs {
'abundance.enoughToShare' => 'Bastante, para compartir con moderación',
'abundance.enoughForMe' => 'Suficiente para mí',
'abundance.runningLow' => 'Queda poca',
'share.add' => '¿La compartes?',
'share.title' => '¿La compartes?',
'share.nudge' => 'Tienes de sobra: podrías compartir un poco.',
'share.private' => 'Solo para mí',
'share.gift' => 'Para regalar',
'share.exchange' => 'Para intercambiar',
'share.sell' => 'En venta',
'share.filterChip' => 'Comparto',
'share.printCatalog' => 'Imprimir lo que comparto',
'share.catalogTitle' => 'Lo que comparto',
'share.catalogSaved' => 'Catálogo guardado',
'share.cancelled' => 'Cancelado',
'cropCalendar.add' => 'Calendario de cultivo',
'cropCalendar.title' => 'Calendario de cultivo',
'cropCalendar.sow' => 'Siembra',

View file

@ -0,0 +1,132 @@
import 'dart:typed_data';
import 'package:flutter/services.dart' show rootBundle;
import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart' as pw;
import 'file_service.dart';
/// One printable line of the "what I share" catalog. All strings arrive
/// already localized this service knows nothing about i18n or the domain.
class ShareCatalogRow {
const ShareCatalogRow({
required this.name,
required this.mode,
this.scientificName,
this.details,
});
/// The variety's label, as its keeper writes it.
final String name;
/// Scientific name of the linked species, if any (rendered in italics).
final String? scientificName;
/// The batch facts line ("Seeds · 2024 · 2 cobs"), if any.
final String? details;
/// On what terms it is offered ("To give away" / "To swap" / "For sale").
final String mode;
}
/// Renders the "what I share" catalog as a PDF and hands it to the user via
/// the save dialog a paper bridge for fairs and meet-ups, no network needed.
class ShareCatalogService {
ShareCatalogService({required FileService files}) : _files = files;
final FileService _files;
/// Builds the catalog PDF. Pure of platform channels apart from loading the
/// bundled font, so it runs (and is tested) on the host.
///
/// The embedded DejaVu face covers Latin (extended), Cyrillic and Greek.
/// Like the OCR traineddata, the PDF font is a per-locale resource: wider
/// scripts (Arabic, CJK) ship alongside their locales, not hardcoded here.
Future<Uint8List> buildPdf({
required String title,
required String date,
required List<ShareCatalogRow> rows,
}) async {
final base = pw.Font.ttf(
await rootBundle.load('assets/fonts/DejaVuSans.ttf'),
);
final bold = pw.Font.ttf(
await rootBundle.load('assets/fonts/DejaVuSans-Bold.ttf'),
);
final doc = pw.Document(
theme: pw.ThemeData.withFont(base: base, bold: bold, italic: base),
);
doc.addPage(
pw.MultiPage(
pageFormat: PdfPageFormat.a4,
build: (context) => [
pw.Text(title, style: pw.TextStyle(font: bold, fontSize: 20)),
pw.SizedBox(height: 2),
pw.Text(
date,
style: const pw.TextStyle(fontSize: 10, color: PdfColors.grey700),
),
pw.SizedBox(height: 12),
for (final row in rows)
pw.Container(
padding: const pw.EdgeInsets.symmetric(vertical: 6),
decoration: const pw.BoxDecoration(
border: pw.Border(
bottom: pw.BorderSide(color: PdfColors.grey300, width: 0.5),
),
),
child: pw.Row(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Expanded(
child: pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Text(
row.name,
style: pw.TextStyle(font: bold, fontSize: 12),
),
if (row.scientificName != null)
pw.Text(
row.scientificName!,
style: pw.TextStyle(
fontSize: 10,
fontStyle: pw.FontStyle.italic,
color: PdfColors.grey700,
),
),
if (row.details != null)
pw.Text(
row.details!,
style: const pw.TextStyle(fontSize: 10),
),
],
),
),
pw.SizedBox(width: 8),
pw.Text(row.mode, style: const pw.TextStyle(fontSize: 10)),
],
),
),
],
),
);
return doc.save();
}
/// Builds the PDF and asks the user where to keep it. Returns true when
/// saved, false when they cancelled the dialog.
Future<bool> saveCatalog({
required String title,
required String date,
required String suggestedName,
required List<ShareCatalogRow> rows,
}) async {
final bytes = await buildPdf(title: title, date: date, rows: rows);
final path = await _files.saveFile(
suggestedName: suggestedName,
bytes: bytes,
);
return path != null;
}
}

View file

@ -18,6 +18,7 @@ class InventoryState extends Equatable {
this.typeFilter = const {},
this.organicOnly = false,
this.needsReproductionOnly = false,
this.sharingOnly = false,
this.loading = true,
});
@ -42,6 +43,10 @@ class InventoryState extends Equatable {
/// When true, keep only varieties flagged "needs reproducing this season".
final bool needsReproductionOnly;
/// When true, keep only varieties with some lot offered to others the
/// "what I share" view.
final bool sharingOnly;
final bool loading;
/// Categories present across all items, in display order (deduped), so the
@ -60,15 +65,16 @@ class InventoryState extends Equatable {
final q = query.trim().toLowerCase();
return items.where((i) {
if (q.isNotEmpty && !i.label.toLowerCase().contains(q)) return false;
if (categoryFilter.isNotEmpty &&
!categoryFilter.contains(i.category)) {
if (categoryFilter.isNotEmpty && !categoryFilter.contains(i.category)) {
return false;
}
if (typeFilter.isNotEmpty && i.lotTypes.intersection(typeFilter).isEmpty) {
if (typeFilter.isNotEmpty &&
i.lotTypes.intersection(typeFilter).isEmpty) {
return false;
}
if (organicOnly && !i.isOrganic) return false;
if (needsReproductionOnly && !i.needsReproduction) return false;
if (sharingOnly && !i.isShared) return false;
return true;
}).toList();
}
@ -81,6 +87,10 @@ class InventoryState extends Equatable {
/// that filter chip when it would match something.
bool get hasNeedsReproduction => items.any((i) => i.needsReproduction);
/// Whether any variety has a lot offered to others, so the UI only offers
/// the "sharing" chip and the printable catalog when they'd show something.
bool get hasShared => items.any((i) => i.isShared);
InventoryState copyWith({
List<VarietyListItem>? items,
List<VarietyListItem>? drafts,
@ -89,6 +99,7 @@ class InventoryState extends Equatable {
Set<LotType>? typeFilter,
bool? organicOnly,
bool? needsReproductionOnly,
bool? sharingOnly,
bool? loading,
}) {
return InventoryState(
@ -100,6 +111,7 @@ class InventoryState extends Equatable {
organicOnly: organicOnly ?? this.organicOnly,
needsReproductionOnly:
needsReproductionOnly ?? this.needsReproductionOnly,
sharingOnly: sharingOnly ?? this.sharingOnly,
loading: loading ?? this.loading,
);
}
@ -113,6 +125,7 @@ class InventoryState extends Equatable {
typeFilter,
organicOnly,
needsReproductionOnly,
sharingOnly,
loading,
];
}
@ -157,9 +170,12 @@ class InventoryCubit extends Cubit<InventoryState> {
emit(state.copyWith(organicOnly: !state.organicOnly));
/// Toggles the "needs reproducing only" filter.
void toggleNeedsReproductionOnly() => emit(
state.copyWith(needsReproductionOnly: !state.needsReproductionOnly),
);
void toggleNeedsReproductionOnly() =>
emit(state.copyWith(needsReproductionOnly: !state.needsReproductionOnly));
/// Toggles the "what I share" filter.
void toggleSharingOnly() =>
emit(state.copyWith(sharingOnly: !state.sharingOnly));
/// Clears all filters (search is left untouched).
void clearFilters() => emit(
@ -168,6 +184,7 @@ class InventoryCubit extends Cubit<InventoryState> {
typeFilter: const {},
organicOnly: false,
needsReproductionOnly: false,
sharingOnly: false,
),
);

View file

@ -80,6 +80,7 @@ class VarietyDetailCubit extends Cubit<VarietyDetailState> {
String? originPlace,
Abundance? abundance,
PreservationFormat? preservationFormat,
OfferStatus offerStatus = OfferStatus.private,
}) => _repo.addLot(
varietyId: varietyId,
type: type,
@ -91,6 +92,7 @@ class VarietyDetailCubit extends Cubit<VarietyDetailState> {
originPlace: originPlace,
abundance: abundance,
preservationFormat: preservationFormat,
offerStatus: offerStatus,
);
Future<void> updateLot({
@ -104,6 +106,7 @@ class VarietyDetailCubit extends Cubit<VarietyDetailState> {
String? originPlace,
Abundance? abundance,
PreservationFormat? preservationFormat,
OfferStatus offerStatus = OfferStatus.private,
}) => _repo.updateLot(
lotId: lotId,
type: type,
@ -115,6 +118,7 @@ class VarietyDetailCubit extends Cubit<VarietyDetailState> {
originPlace: originPlace,
abundance: abundance,
preservationFormat: preservationFormat,
offerStatus: offerStatus,
);
Future<void> deleteLot(String lotId) => _repo.softDeleteLot(lotId);

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

View file

@ -52,6 +52,9 @@ dependencies:
image: ^4.3.0
# Save/open dialogs for the inventory export/import (MIT license).
file_picker: ^10.1.2
# Pure-Dart PDF generation (Apache-2.0) for the printable "what I share"
# catalog. No platform channels, so the whole flow is host-testable.
pdf: ^3.11.3
path: ^1.9.0
path_provider: ^2.1.5
@ -119,6 +122,12 @@ flutter:
# Real packet photos for the on-device OCR validation integration test.
# Remove before shipping if you don't want them in the bundle.
- assets/ocr_fixtures/
# Embedded in generated PDFs (printable catalog). DejaVu covers Latin
# (extended), Cyrillic and Greek; like the OCR packs, PDF fonts are a
# per-locale resource — swap in wider coverage (Arabic, CJK) as locales
# land. Free license (Bitstream Vera / public domain additions).
- assets/fonts/DejaVuSans.ttf
- assets/fonts/DejaVuSans-Bold.ttf
fonts:
# Custom seed-themed icon glyphs (chars ak). See docs/mockups/seedks-glyphs.png.
- family: seedks

View file

@ -0,0 +1,104 @@
import 'package:commons_core/commons_core.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:tane/data/species_repository.dart';
import 'package:tane/data/variety_repository.dart';
import 'package:tane/db/database.dart';
import 'package:tane/db/enums.dart';
import '../support/test_support.dart';
void main() {
late AppDatabase db;
late VarietyRepository repo;
setUp(() {
db = newTestDatabase();
repo = newTestRepository(db);
});
tearDown(() => db.close());
test('addLot and updateLot persist the share terms', () async {
final id = await repo.addQuickVariety(label: 'Maize');
final lotId = await repo.addLot(
varietyId: id,
offerStatus: OfferStatus.shared,
);
var detail = await repo.watchVariety(id).first;
expect(detail!.lots.single.offerStatus, OfferStatus.shared);
await repo.updateLot(
lotId: lotId,
type: LotType.seed,
offerStatus: OfferStatus.exchange,
);
detail = await repo.watchVariety(id).first;
expect(detail!.lots.single.offerStatus, OfferStatus.exchange);
});
test('list items flag varieties with some lot offered', () async {
final sharedId = await repo.addQuickVariety(label: 'Shared bean');
await repo.addLot(varietyId: sharedId, offerStatus: OfferStatus.sell);
final privateId = await repo.addQuickVariety(label: 'Private bean');
await repo.addLot(varietyId: privateId);
final items = await repo.watchInventoryView().first;
final byLabel = {for (final i in items.items) i.label: i};
expect(byLabel['Shared bean']!.isShared, isTrue);
expect(byLabel['Private bean']!.isShared, isFalse);
});
test('sharedCatalog lists only offered lots, sorted and joined', () async {
final species = newTestSpeciesRepository(db);
await species.seedBundled(const [
SpeciesSeed(
scientificName: 'Zea mays',
family: 'Poaceae',
commonNames: {
'en': ['Maize'],
},
),
]);
final maizeId = await repo.addQuickVariety(label: 'Rebordanes maize');
final matches = await species.search('Zea', languageCode: 'en');
await repo.linkSpecies(maizeId, matches.single.id);
await repo.addLot(
varietyId: maizeId,
harvestYear: 2024,
quantity: const Quantity(kind: QuantityKind.cob, count: 2),
offerStatus: OfferStatus.shared,
);
// A private lot of the same variety stays out of the catalog.
await repo.addLot(varietyId: maizeId, harvestYear: 2023);
final beanId = await repo.addQuickVariety(label: 'Alubia de Tolosa');
await repo.addLot(
varietyId: beanId,
abundance: Abundance.plentyToShare,
offerStatus: OfferStatus.exchange,
);
// A deleted shared lot disappears with its tombstone.
final goneId = await repo.addQuickVariety(label: 'Gone');
final goneLot = await repo.addLot(
varietyId: goneId,
offerStatus: OfferStatus.shared,
);
await repo.softDeleteLot(goneLot);
final catalog = await repo.sharedCatalog();
expect(catalog, hasLength(2));
// Sorted by label, case-insensitively.
expect(catalog.first.varietyLabel, 'Alubia de Tolosa');
expect(catalog.first.offerStatus, OfferStatus.exchange);
expect(catalog.first.abundance, Abundance.plentyToShare);
final maize = catalog.last;
expect(maize.varietyLabel, 'Rebordanes maize');
expect(maize.scientificName, 'Zea mays');
expect(maize.category, 'Poaceae');
expect(maize.harvestYear, 2024);
expect(maize.quantity!.count, 2);
});
}

View file

@ -0,0 +1,66 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:tane/services/file_service.dart';
import 'package:tane/services/share_catalog_service.dart';
/// Captures the bytes handed to the save dialog instead of touching disk.
class _RecordingFileService implements FileService {
Uint8List? saved;
String? savedName;
@override
Future<String?> saveFile({
required String suggestedName,
required Uint8List bytes,
}) async {
saved = bytes;
savedName = suggestedName;
return '/dev/null/$suggestedName';
}
@override
Future<Uint8List?> pickFileBytes({List<String>? allowedExtensions}) async =>
null;
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test('buildPdf renders a well-formed PDF with the given rows', () async {
final service = ShareCatalogService(files: _RecordingFileService());
final bytes = await service.buildPdf(
title: 'What I share',
date: 'July 9, 2026',
rows: const [
ShareCatalogRow(
name: 'Rebordanes maize',
scientificName: 'Zea mays',
details: 'Seeds · Year 2024 · 2 cobs',
mode: 'To give away',
),
ShareCatalogRow(name: 'Alubia de Tolosa', mode: 'To swap'),
],
);
// %PDF header + non-trivial body; text content is compressed, so the
// human-readable row assertions live in the widget/data tests.
expect(String.fromCharCodes(bytes.take(5)), '%PDF-');
expect(bytes.length, greaterThan(1000));
});
test('saveCatalog hands the PDF to the save dialog', () async {
final files = _RecordingFileService();
final service = ShareCatalogService(files: files);
final saved = await service.saveCatalog(
title: 'What I share',
date: 'July 9, 2026',
suggestedName: 'tanemaki-catalog-2026-07-09.pdf',
rows: const [ShareCatalogRow(name: 'Maize', mode: 'To give away')],
);
expect(saved, isTrue);
expect(files.savedName, 'tanemaki-catalog-2026-07-09.pdf');
expect(String.fromCharCodes(files.saved!.take(5)), '%PDF-');
});
}

View file

@ -103,6 +103,20 @@ void main() {
expect(cubit.state.visibleItems.single.label, 'Tired');
});
test('sharing filter keeps only varieties with an offered lot', () async {
final sharedId = await repo.addQuickVariety(label: 'Shared');
await repo.addLot(varietyId: sharedId, offerStatus: OfferStatus.shared);
final privateId = await repo.addQuickVariety(label: 'Private');
await repo.addLot(varietyId: privateId);
await waitFor(cubit, (s) => s.hasShared && s.items.length == 2);
cubit.toggleSharingOnly();
expect(cubit.state.visibleItems.single.label, 'Shared');
cubit.toggleSharingOnly();
expect(cubit.state.visibleItems, hasLength(2));
});
test('clearFilters resets every filter but keeps the search query', () async {
await repo.addQuickVariety(label: 'Maize', category: 'Poaceae');
await waitFor(cubit, (s) => s.items.isNotEmpty);
@ -112,13 +126,15 @@ void main() {
..toggleCategory('Poaceae')
..toggleType(LotType.seed)
..toggleOrganicOnly()
..toggleNeedsReproductionOnly();
..toggleNeedsReproductionOnly()
..toggleSharingOnly();
cubit.clearFilters();
expect(cubit.state.categoryFilter, isEmpty);
expect(cubit.state.typeFilter, isEmpty);
expect(cubit.state.organicOnly, isFalse);
expect(cubit.state.needsReproductionOnly, isFalse);
expect(cubit.state.sharingOnly, isFalse);
expect(cubit.state.query, 'mai');
});

View file

@ -0,0 +1,112 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:tane/db/database.dart';
import 'package:tane/db/enums.dart';
import 'package:tane/ui/inventory_list_screen.dart';
import '../support/test_support.dart';
void main() {
late AppDatabase db;
setUp(() => db = newTestDatabase());
tearDown(() => db.close());
testWidgets('marking a lot to give away shows on its tile', (tester) async {
final repo = newTestRepository(db);
final id = await repo.addQuickVariety(label: 'Maize');
await repo.addLot(varietyId: id, harvestYear: 2024);
await tester.pumpWidget(
wrapDetail(
repository: repo,
varietyId: id,
species: newTestSpeciesRepository(db),
),
);
await tester.pumpAndSettle();
await tester.tap(find.textContaining('Year 2024')); // open the lot
await tester.pumpAndSettle();
await tester.ensureVisible(find.byKey(const Key('lot.addShare')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('lot.addShare')));
await tester.pumpAndSettle();
await tester.ensureVisible(find.byKey(const Key('share.shared')));
await tester.tap(find.byKey(const Key('share.shared')));
await tester.pumpAndSettle();
await tester.ensureVisible(find.byKey(const Key('addLot.save')));
await tester.tap(find.byKey(const Key('addLot.save')));
await tester.pumpAndSettle();
// The lot line now carries the share terms.
expect(find.textContaining('To give away'), findsOneWidget);
await disposeTree(tester);
});
testWidgets('declaring plenty reveals the share choice with a nudge', (
tester,
) async {
final repo = newTestRepository(db);
final id = await repo.addQuickVariety(label: 'Maize');
await tester.pumpWidget(
wrapDetail(
repository: repo,
varietyId: id,
species: newTestSpeciesRepository(db),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('detail.addLot')));
await tester.pumpAndSettle();
// Reveal abundance and declare plenty.
await tester.ensureVisible(find.byKey(const Key('lot.addAbundance')));
await tester.tap(find.byKey(const Key('lot.addAbundance')));
await tester.pumpAndSettle();
await tester.ensureVisible(
find.byKey(const Key('abundance.plentyToShare')),
);
await tester.tap(find.byKey(const Key('abundance.plentyToShare')));
await tester.pumpAndSettle();
// The share section reveals itself, still private, with a soft nudge.
expect(find.byKey(const Key('share.private')), findsOneWidget);
expect(find.byKey(const Key('share.nudge')), findsOneWidget);
await disposeTree(tester);
});
testWidgets('the sharing filter narrows the list to what I share', (
tester,
) async {
final repo = newTestRepository(db);
final sharedId = await repo.addQuickVariety(label: 'Shared bean');
await repo.addLot(varietyId: sharedId, offerStatus: OfferStatus.exchange);
final privateId = await repo.addQuickVariety(label: 'Private bean');
await repo.addLot(varietyId: privateId);
await tester.pumpWidget(
wrapScreen(repository: repo, child: const InventoryListScreen()),
);
await tester.pumpAndSettle();
// Both listed; the shared one carries its badge, and the print action and
// filter chip are offered.
expect(find.text('Shared bean'), findsOneWidget);
expect(find.text('Private bean'), findsOneWidget);
expect(find.byKey(Key('inventory.shared.$sharedId')), findsOneWidget);
expect(find.byKey(const Key('inventory.printCatalog')), findsOneWidget);
await tester.tap(find.byKey(const Key('inventory.filter.sharing')));
await tester.pumpAndSettle();
expect(find.text('Shared bean'), findsOneWidget);
expect(find.text('Private bean'), findsNothing);
await disposeTree(tester);
});
}