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:
parent
e3ec855630
commit
ba87bf2719
20 changed files with 982 additions and 36 deletions
104
apps/app_seeds/test/data/shared_catalog_test.dart
Normal file
104
apps/app_seeds/test/data/shared_catalog_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
66
apps/app_seeds/test/services/share_catalog_service_test.dart
Normal file
66
apps/app_seeds/test/services/share_catalog_service_test.dart
Normal 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-');
|
||||
});
|
||||
}
|
||||
|
|
@ -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');
|
||||
});
|
||||
|
||||
|
|
|
|||
112
apps/app_seeds/test/ui/share_flow_test.dart
Normal file
112
apps/app_seeds/test/ui/share_flow_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue