feat(market): search + pull-to-refresh in the market list

Match the inventory patterns the market was missing:
- Search: OffersState gains a `query` + `visibleOffers` getter and
  OffersCubit.search() filters the already-discovered offers locally by
  summary (case-insensitive), never re-hitting the transport. The filter
  survives a refresh. Rounded search field mirrors inventory's.
- Pull-to-refresh: RefreshIndicator re-runs discovery for the current
  area (empty state included, so a swipe re-scans). Text filter kept.
- i18n: market.searchHint / market.noMatches (en/es/pt).

Note: the shared slang output (strings*.g.dart, incl. the generated
Asturian locale) is regenerated wholesale, so it also carries the
in-progress `ast` locale keys already present in the working tree.
This commit is contained in:
vjrj 2026-07-10 15:40:13 +02:00
parent bd18978aff
commit beb29915d8
11 changed files with 1614 additions and 21 deletions

View file

@ -240,26 +240,95 @@ class MarketBody extends StatelessWidget {
),
);
}
final cubit = context.read<OffersCubit>();
// Pull-to-refresh re-runs discovery for the current area (keeping the
// text filter), so the list is never stuck on a stale scan.
Future<void> refresh() => cubit.discover(state.areaGeohash);
// Nothing discovered at all: keep the friendly empty state, but make it
// pull-to-refreshable so a swipe re-scans.
if (state.offers.isEmpty) {
return _EmptyState(
icon: Icons.grass_outlined,
title: t.market.empty,
body: '',
return RefreshIndicator(
onRefresh: refresh,
child: ListView(
// AlwaysScrollable so the pull gesture works with a single child.
physics: const AlwaysScrollableScrollPhysics(),
children: [
SizedBox(
height: MediaQuery.of(context).size.height * 0.6,
child: _EmptyState(
icon: Icons.grass_outlined,
title: t.market.empty,
body: '',
),
),
],
),
);
}
return ListView.separated(
padding: const EdgeInsets.all(16),
itemCount: state.offers.length,
separatorBuilder: (_, _) => const SizedBox(height: 12),
itemBuilder: (context, i) {
final o = state.offers[i];
final mine = o.authorPubkeyHex == selfPubkey;
return _OfferCard(
offer: o,
mine: mine,
onContact: mine ? null : () => context.push('/chat/${o.authorPubkeyHex}'),
);
},
final visible = state.visibleOffers;
return Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 4),
child: TextField(
key: const Key('market.search'),
decoration: InputDecoration(
hintText: t.market.searchHint,
prefixIcon: const Icon(Icons.search, color: seedMuted),
hintStyle: const TextStyle(color: seedMuted),
filled: true,
fillColor: Colors.white,
isDense: true,
contentPadding: const EdgeInsets.symmetric(vertical: 14),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(28),
borderSide: BorderSide.none,
),
),
onChanged: cubit.search,
),
),
Expanded(
child: RefreshIndicator(
onRefresh: refresh,
child: visible.isEmpty
// Discovered offers exist, but the search filtered them out.
? ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: [
SizedBox(
height: MediaQuery.of(context).size.height * 0.5,
child: _EmptyState(
icon: Icons.search_off,
title: t.market.noMatches,
body: '',
),
),
],
)
: 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,
onContact: mine
? null
: () =>
context.push('/chat/${o.authorPubkeyHex}'),
);
},
),
),
),
],
);
},
);