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:
parent
bd18978aff
commit
beb29915d8
11 changed files with 1614 additions and 21 deletions
|
|
@ -40,6 +40,7 @@
|
|||
"langEs": "Español",
|
||||
"langEn": "English",
|
||||
"langPt": "Português",
|
||||
"langAst": "Asturianu",
|
||||
"about": "About",
|
||||
"aboutText": "Local-first, encrypted inventory for traditional seeds. AGPL-3.0.",
|
||||
"aboutOpen": "About Tanemaki"
|
||||
|
|
@ -346,6 +347,8 @@
|
|||
"setAreaBody": "Tell the market roughly where you are to see seeds nearby.",
|
||||
"searching": "Looking around your area…",
|
||||
"empty": "No seeds shared near you yet",
|
||||
"searchHint": "Search these seeds",
|
||||
"noMatches": "No shared seeds match your search",
|
||||
"near": "Near you",
|
||||
"contact": "Message",
|
||||
"mine": "You",
|
||||
|
|
|
|||
|
|
@ -346,6 +346,8 @@
|
|||
"setAreaBody": "Dile al mercado tu zona aproximada para ver semillas cerca.",
|
||||
"searching": "Buscando por tu zona…",
|
||||
"empty": "Aún no hay semillas compartidas cerca de ti",
|
||||
"searchHint": "Buscar entre estas semillas",
|
||||
"noMatches": "Ninguna semilla compartida coincide con tu búsqueda",
|
||||
"near": "Cerca de ti",
|
||||
"contact": "Mensaje",
|
||||
"mine": "Tú",
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@
|
|||
"langEs": "Español",
|
||||
"langEn": "English",
|
||||
"langPt": "Português",
|
||||
"langAst": "Asturianu",
|
||||
"about": "Acerca de",
|
||||
"aboutText": "Inventário local e cifrado para sementes tradicionais. AGPL-3.0.",
|
||||
"aboutOpen": "Acerca do Tanemaki"
|
||||
|
|
@ -342,6 +343,8 @@
|
|||
"setAreaBody": "Diz ao mercado a tua zona aproximada para ver sementes por perto.",
|
||||
"searching": "A procurar pela tua zona…",
|
||||
"empty": "Ainda não há sementes partilhadas perto de ti",
|
||||
"searchHint": "Procurar nestas sementes",
|
||||
"noMatches": "Nenhuma semente partilhada corresponde à procura",
|
||||
"near": "Perto de ti",
|
||||
"contact": "Mensagem",
|
||||
"mine": "Tu",
|
||||
|
|
|
|||
|
|
@ -3,10 +3,10 @@
|
|||
/// Source: lib/i18n
|
||||
/// To regenerate, run: `dart run slang`
|
||||
///
|
||||
/// Locales: 3
|
||||
/// Strings: 1073 (357 per locale)
|
||||
/// Locales: 4
|
||||
/// Strings: 1441 (360 per locale)
|
||||
///
|
||||
/// Built on 2026-07-10 at 11:35 UTC
|
||||
/// Built on 2026-07-10 at 13:39 UTC
|
||||
|
||||
// coverage:ignore-file
|
||||
// ignore_for_file: type=lint, unused_import
|
||||
|
|
@ -18,6 +18,7 @@ import 'package:slang/generated.dart';
|
|||
import 'package:slang_flutter/slang_flutter.dart';
|
||||
export 'package:slang_flutter/slang_flutter.dart';
|
||||
|
||||
import 'strings_ast.g.dart' as l_ast;
|
||||
import 'strings_es.g.dart' as l_es;
|
||||
import 'strings_pt.g.dart' as l_pt;
|
||||
part 'strings_en.g.dart';
|
||||
|
|
@ -30,6 +31,7 @@ part 'strings_en.g.dart';
|
|||
/// - if (LocaleSettings.currentLocale == AppLocale.en) // locale check
|
||||
enum AppLocale with BaseAppLocale<AppLocale, Translations> {
|
||||
en(languageCode: 'en'),
|
||||
ast(languageCode: 'ast'),
|
||||
es(languageCode: 'es'),
|
||||
pt(languageCode: 'pt');
|
||||
|
||||
|
|
@ -69,6 +71,12 @@ enum AppLocale with BaseAppLocale<AppLocale, Translations> {
|
|||
cardinalResolver: cardinalResolver,
|
||||
ordinalResolver: ordinalResolver,
|
||||
);
|
||||
case AppLocale.ast:
|
||||
return l_ast.TranslationsAst(
|
||||
overrides: overrides,
|
||||
cardinalResolver: cardinalResolver,
|
||||
ordinalResolver: ordinalResolver,
|
||||
);
|
||||
case AppLocale.es:
|
||||
return l_es.TranslationsEs(
|
||||
overrides: overrides,
|
||||
|
|
|
|||
1417
apps/app_seeds/lib/i18n/strings_ast.g.dart
Normal file
1417
apps/app_seeds/lib/i18n/strings_ast.g.dart
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -219,6 +219,9 @@ class Translations$settings$en {
|
|||
/// en: 'Português'
|
||||
String get langPt => 'Português';
|
||||
|
||||
/// en: 'Asturianu'
|
||||
String get langAst => 'Asturianu';
|
||||
|
||||
/// en: 'About'
|
||||
String get about => 'About';
|
||||
|
||||
|
|
@ -1101,6 +1104,12 @@ class Translations$market$en {
|
|||
/// en: 'No seeds shared near you yet'
|
||||
String get empty => 'No seeds shared near you yet';
|
||||
|
||||
/// en: 'Search these seeds'
|
||||
String get searchHint => 'Search these seeds';
|
||||
|
||||
/// en: 'No shared seeds match your search'
|
||||
String get noMatches => 'No shared seeds match your search';
|
||||
|
||||
/// en: 'Near you'
|
||||
String get near => 'Near you';
|
||||
|
||||
|
|
@ -1778,6 +1787,7 @@ extension on Translations {
|
|||
'settings.langEs' => 'Español',
|
||||
'settings.langEn' => 'English',
|
||||
'settings.langPt' => 'Português',
|
||||
'settings.langAst' => 'Asturianu',
|
||||
'settings.about' => 'About',
|
||||
'settings.aboutText' => 'Local-first, encrypted inventory for traditional seeds. AGPL-3.0.',
|
||||
'settings.aboutOpen' => 'About Tanemaki',
|
||||
|
|
@ -2056,6 +2066,8 @@ extension on Translations {
|
|||
'market.setAreaBody' => 'Tell the market roughly where you are to see seeds nearby.',
|
||||
'market.searching' => 'Looking around your area…',
|
||||
'market.empty' => 'No seeds shared near you yet',
|
||||
'market.searchHint' => 'Search these seeds',
|
||||
'market.noMatches' => 'No shared seeds match your search',
|
||||
'market.near' => 'Near you',
|
||||
'market.contact' => 'Message',
|
||||
'market.mine' => 'You',
|
||||
|
|
|
|||
|
|
@ -620,6 +620,8 @@ class _Translations$market$es extends Translations$market$en {
|
|||
@override String get setAreaBody => 'Dile al mercado tu zona aproximada para ver semillas cerca.';
|
||||
@override String get searching => 'Buscando por tu zona…';
|
||||
@override String get empty => 'Aún no hay semillas compartidas cerca de ti';
|
||||
@override String get searchHint => 'Buscar entre estas semillas';
|
||||
@override String get noMatches => 'Ninguna semilla compartida coincide con tu búsqueda';
|
||||
@override String get near => 'Cerca de ti';
|
||||
@override String get contact => 'Mensaje';
|
||||
@override String get mine => 'Tú';
|
||||
|
|
@ -1358,6 +1360,8 @@ extension on TranslationsEs {
|
|||
'market.setAreaBody' => 'Dile al mercado tu zona aproximada para ver semillas cerca.',
|
||||
'market.searching' => 'Buscando por tu zona…',
|
||||
'market.empty' => 'Aún no hay semillas compartidas cerca de ti',
|
||||
'market.searchHint' => 'Buscar entre estas semillas',
|
||||
'market.noMatches' => 'Ninguna semilla compartida coincide con tu búsqueda',
|
||||
'market.near' => 'Cerca de ti',
|
||||
'market.contact' => 'Mensaje',
|
||||
'market.mine' => 'Tú',
|
||||
|
|
|
|||
|
|
@ -157,6 +157,7 @@ class _Translations$settings$pt extends Translations$settings$en {
|
|||
@override String get langEs => 'Español';
|
||||
@override String get langEn => 'English';
|
||||
@override String get langPt => 'Português';
|
||||
@override String get langAst => 'Asturianu';
|
||||
@override String get about => 'Acerca de';
|
||||
@override String get aboutText => 'Inventário local e cifrado para sementes tradicionais. AGPL-3.0.';
|
||||
@override String get aboutOpen => 'Acerca do Tanemaki';
|
||||
|
|
@ -616,6 +617,8 @@ class _Translations$market$pt extends Translations$market$en {
|
|||
@override String get setAreaBody => 'Diz ao mercado a tua zona aproximada para ver sementes por perto.';
|
||||
@override String get searching => 'A procurar pela tua zona…';
|
||||
@override String get empty => 'Ainda não há sementes partilhadas perto de ti';
|
||||
@override String get searchHint => 'Procurar nestas sementes';
|
||||
@override String get noMatches => 'Nenhuma semente partilhada corresponde à procura';
|
||||
@override String get near => 'Perto de ti';
|
||||
@override String get contact => 'Mensagem';
|
||||
@override String get mine => 'Tu';
|
||||
|
|
@ -1076,6 +1079,7 @@ extension on TranslationsPt {
|
|||
'settings.langEs' => 'Español',
|
||||
'settings.langEn' => 'English',
|
||||
'settings.langPt' => 'Português',
|
||||
'settings.langAst' => 'Asturianu',
|
||||
'settings.about' => 'Acerca de',
|
||||
'settings.aboutText' => 'Inventário local e cifrado para sementes tradicionais. AGPL-3.0.',
|
||||
'settings.aboutOpen' => 'Acerca do Tanemaki',
|
||||
|
|
@ -1350,6 +1354,8 @@ extension on TranslationsPt {
|
|||
'market.setAreaBody' => 'Diz ao mercado a tua zona aproximada para ver sementes por perto.',
|
||||
'market.searching' => 'A procurar pela tua zona…',
|
||||
'market.empty' => 'Ainda não há sementes partilhadas perto de ti',
|
||||
'market.searchHint' => 'Procurar nestas sementes',
|
||||
'market.noMatches' => 'Nenhuma semente partilhada corresponde à procura',
|
||||
'market.near' => 'Perto de ti',
|
||||
'market.contact' => 'Mensagem',
|
||||
'market.mine' => 'Tu',
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ class OffersState extends Equatable {
|
|||
const OffersState({
|
||||
this.offers = const [],
|
||||
this.areaGeohash = '',
|
||||
this.query = '',
|
||||
this.searching = false,
|
||||
this.publishing = false,
|
||||
this.hasSearched = false,
|
||||
|
|
@ -28,6 +29,9 @@ class OffersState extends Equatable {
|
|||
/// The coarse area currently being browsed.
|
||||
final String areaGeohash;
|
||||
|
||||
/// Free-text filter over [offers] typed in the search box (empty = show all).
|
||||
final String query;
|
||||
|
||||
final bool searching;
|
||||
final bool publishing;
|
||||
|
||||
|
|
@ -38,9 +42,18 @@ class OffersState extends Equatable {
|
|||
/// Last error, in human terms for the UI (null when fine).
|
||||
final String? error;
|
||||
|
||||
/// [offers] narrowed by [query], matched against the offer summary. Kept
|
||||
/// separate from [offers] so a search never drops the underlying discoveries.
|
||||
List<Offer> get visibleOffers {
|
||||
final q = query.trim().toLowerCase();
|
||||
if (q.isEmpty) return offers;
|
||||
return offers.where((o) => o.summary.toLowerCase().contains(q)).toList();
|
||||
}
|
||||
|
||||
OffersState copyWith({
|
||||
List<Offer>? offers,
|
||||
String? areaGeohash,
|
||||
String? query,
|
||||
bool? searching,
|
||||
bool? publishing,
|
||||
bool? hasSearched,
|
||||
|
|
@ -49,6 +62,7 @@ class OffersState extends Equatable {
|
|||
return OffersState(
|
||||
offers: offers ?? this.offers,
|
||||
areaGeohash: areaGeohash ?? this.areaGeohash,
|
||||
query: query ?? this.query,
|
||||
searching: searching ?? this.searching,
|
||||
publishing: publishing ?? this.publishing,
|
||||
hasSearched: hasSearched ?? this.hasSearched,
|
||||
|
|
@ -58,7 +72,7 @@ class OffersState extends Equatable {
|
|||
|
||||
@override
|
||||
List<Object?> get props =>
|
||||
[offers, areaGeohash, searching, publishing, hasSearched, error];
|
||||
[offers, areaGeohash, query, searching, publishing, hasSearched, error];
|
||||
}
|
||||
|
||||
/// Drives offer discovery and publishing over an [OfferTransport]. Depends on
|
||||
|
|
@ -92,6 +106,7 @@ class OffersCubit extends Cubit<OffersState> {
|
|||
_searchTimeout?.cancel();
|
||||
emit(OffersState(
|
||||
areaGeohash: geohashPrefix,
|
||||
query: state.query, // keep the text filter across a refresh
|
||||
searching: true,
|
||||
hasSearched: true,
|
||||
));
|
||||
|
|
@ -111,6 +126,10 @@ class OffersCubit extends Cubit<OffersState> {
|
|||
});
|
||||
}
|
||||
|
||||
/// Narrows the visible offers to those whose summary matches [query]. Purely
|
||||
/// local over the already-discovered list; does not re-hit the transport.
|
||||
void search(String query) => emit(state.copyWith(query: query));
|
||||
|
||||
/// Publishes [offer]; returns the transport's verdict. No-op result when
|
||||
/// offline.
|
||||
Future<PublishResult> publish(Offer offer) async {
|
||||
|
|
|
|||
|
|
@ -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}'),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -102,6 +102,56 @@ void main() {
|
|||
await cubit.close();
|
||||
});
|
||||
|
||||
test('search narrows visible offers by summary, keeping the full list',
|
||||
() async {
|
||||
final transport = FakeOfferTransport();
|
||||
for (final s in ['Tomate rosa', 'Judía verde', 'Tomate rojo']) {
|
||||
await transport.publish(OfferMapper.fromSharedLot(
|
||||
lotId: s,
|
||||
authorPubkeyHex: 'ab' * 32,
|
||||
summary: s,
|
||||
sharing: OfferStatus.shared,
|
||||
areaGeohash: 'sp3e9',
|
||||
));
|
||||
}
|
||||
final cubit = OffersCubit(transport);
|
||||
await cubit.discover('sp3');
|
||||
await pumpEventQueue();
|
||||
expect(cubit.state.offers, hasLength(3));
|
||||
|
||||
cubit.search('tomate'); // case-insensitive
|
||||
expect(cubit.state.visibleOffers.map((o) => o.summary),
|
||||
containsAll(['Tomate rosa', 'Tomate rojo']));
|
||||
expect(cubit.state.visibleOffers, hasLength(2));
|
||||
expect(cubit.state.offers, hasLength(3)); // underlying list untouched
|
||||
|
||||
cubit.search(''); // clearing shows everything again
|
||||
expect(cubit.state.visibleOffers, hasLength(3));
|
||||
await cubit.close();
|
||||
});
|
||||
|
||||
test('the search filter survives a refresh (re-discovery)', () async {
|
||||
final transport = FakeOfferTransport();
|
||||
await transport.publish(OfferMapper.fromSharedLot(
|
||||
lotId: 'lot-1',
|
||||
authorPubkeyHex: 'ab' * 32,
|
||||
summary: 'Pimiento',
|
||||
sharing: OfferStatus.shared,
|
||||
areaGeohash: 'sp3e9',
|
||||
));
|
||||
final cubit = OffersCubit(transport);
|
||||
await cubit.discover('sp3');
|
||||
await pumpEventQueue();
|
||||
cubit.search('pim');
|
||||
expect(cubit.state.query, 'pim');
|
||||
|
||||
await cubit.discover('sp3'); // pull-to-refresh
|
||||
await pumpEventQueue();
|
||||
expect(cubit.state.query, 'pim'); // kept across the refresh
|
||||
expect(cubit.state.visibleOffers, hasLength(1));
|
||||
await cubit.close();
|
||||
});
|
||||
|
||||
test('a different area finds nothing', () async {
|
||||
final transport = FakeOfferTransport();
|
||||
await transport.publish(OfferMapper.fromSharedLot(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue