perf(market): paginate Nostr offer discovery, infinite scroll, memory cap

Bound market memory and let large areas page instead of loading everything:
- DiscoveryQuery gains an until cursor; OfferTransport gains discoverPage()
  (one-shot, EOSE-bounded, newest-first) alongside the existing live discover()
  stream, which now accepts since so it only carries NEW offers going forward.
- NostrOfferTransport.discoverPage sorts by created_at desc and derives the
  next cursor from the oldest event seen (not the oldest type-matched one, so
  filtering never skips a page).
- OffersCubit: discover() fetches the first page + opens a since=now live sub;
  loadNextPage() pages further back; the in-memory list is capped at 400
  offers (each can carry a ~40KB inline photo thumbnail, so unbounded growth in
  a busy area was a real OOM risk).
- market_screen: infinite scroll via a scroll-position trigger + footer
  spinner, instead of a single unbounded ListView.
- Added discoverPage unit tests (commons_core) and cubit pagination tests
  (first page/cursor, accumulation, cap, cross-page dedup).
This commit is contained in:
vjrj 2026-07-20 22:47:12 +02:00
parent 3de01bd948
commit f17ae7751c
9 changed files with 513 additions and 43 deletions

View file

@ -0,0 +1,45 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:tane/ui/inventory_list_screen.dart';
import '../support/test_support.dart';
/// The inventory list must render lazily: with a large catalogue only the tiles
/// near the viewport are built, not one widget per row. Guards the regression
/// from `ListView(children: )` (every tile built upfront) back in.
void main() {
testWidgets('builds only the on-screen tiles for a large inventory',
(tester) async {
final db = newTestDatabase();
final repo = newTestRepository(db);
// Enough rows that an eager list would build hundreds of tiles at once.
for (var i = 0; i < 300; i++) {
await repo.addQuickVariety(
label: 'Variety ${i.toString().padLeft(3, '0')}',
category: i.isEven ? 'Poaceae' : 'Fabaceae',
);
}
await tester.pumpWidget(
wrapScreen(repository: repo, child: const InventoryListScreen()),
);
// Let the debounced inventory stream emit and the async load resolve
// (bounded pumps the screen holds a live Drift stream, so pumpAndSettle
// would hang; see testing.md).
await tester.pump();
await tester.pump(const Duration(milliseconds: 400));
await tester.pump(const Duration(milliseconds: 100));
// Every variety tile is a ListTile; a lazy list builds only a viewport-worth.
final built = find.byType(ListTile).evaluate().length;
expect(built, greaterThan(0), reason: 'the list rendered some tiles');
expect(
built,
lessThan(50),
reason: 'lazy list should build ~a screenful, not all 300 ($built built)',
);
await disposeTree(tester);
await db.close();
});
}