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

@ -414,23 +414,44 @@ class MarketBody extends StatelessWidget {
),
],
)
: ListView.separated(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16),
itemCount: visible.length,
separatorBuilder: (_, _) => const SizedBox(height: 12),
itemBuilder: (context, i) {
final o = visible[i];
final mine = o.authorPubkeyHex == selfPubkey;
return _OfferCard(
offer: o,
mine: mine,
onTap: () async {
await context.push('/market/offer', extra: o);
await onOfferClosed?.call();
},
);
: NotificationListener<ScrollNotification>(
// Page in older offers as the list nears its end, so a
// large area streams in on demand rather than all at
// once. The cubit guards against overlapping loads.
onNotification: (n) {
if (state.canLoadMore &&
!state.loadingMore &&
n.metrics.axis == Axis.vertical &&
n.metrics.extentAfter < 500) {
cubit.loadNextPage();
}
return false;
},
child: ListView.separated(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16),
// A trailing spinner row while the next page loads.
itemCount: visible.length + (state.loadingMore ? 1 : 0),
separatorBuilder: (_, _) => const SizedBox(height: 12),
itemBuilder: (context, i) {
if (i >= visible.length) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Center(child: CircularProgressIndicator()),
);
}
final o = visible[i];
final mine = o.authorPubkeyHex == selfPubkey;
return _OfferCard(
offer: o,
mine: mine,
onTap: () async {
await context.push('/market/offer', extra: o);
await onOfferClosed?.call();
},
);
},
),
),
),
),