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

@ -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);
});
}