feat(market): fix publish-duplicates, add offer detail page + filters
The vecino/market view had three gaps: - Publishing inventory duplicated the listing. discover() appended every offer the relay streamed, but relays legitimately resend an addressable NIP-99 event (stored copy + live echo on publish). Merge by (authorPubkeyHex, id) so each offer appears once; two authors reusing a lot id stay distinct. - No product detail. Tapping a card jumped straight to chat. Add a read-only "product page" (MarketOfferDetailScreen) with title, mode, category, eco badge, coarse location, price, a "Shared by" seller section (profile fetched by pubkey) and the message button. Reached via a new /market/offer route; cards are now fully tappable. Shows only what the network already carries — no photos/description added to the wire. - No filters. Add a filter bar (type, category, eco) mirroring inventory, each chip group shown only when a discovered offer can match it. To make the eco filter real, publish the organic flag on the wire: Offer.isOrganic -> NIP-99 'organic' tag -> OfferMapper -> ShareableLot. Tests: commons_core organic round-trip; app-side dedup, two-author separation, filter logic, mapper passthrough; detail-screen widget tests. i18n keys added to en/es/pt/ast and slang regenerated.
This commit is contained in:
parent
138027a8ef
commit
0213152bfb
21 changed files with 982 additions and 107 deletions
|
|
@ -1,3 +1,4 @@
|
|||
import 'package:commons_core/commons_core.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
|
@ -25,6 +26,7 @@ import 'ui/chat_screen.dart';
|
|||
import 'ui/home_screen.dart';
|
||||
import 'ui/intro_screen.dart';
|
||||
import 'ui/inventory_list_screen.dart';
|
||||
import 'ui/market_offer_detail_screen.dart';
|
||||
import 'ui/market_screen.dart';
|
||||
import 'ui/offline_banner.dart';
|
||||
import 'ui/profile_screen.dart';
|
||||
|
|
@ -123,6 +125,20 @@ class TaneApp extends StatelessWidget {
|
|||
outbox: outbox,
|
||||
),
|
||||
),
|
||||
if (social != null && socialSettings != null)
|
||||
GoRoute(
|
||||
path: '/market/offer',
|
||||
builder: (context, state) {
|
||||
final offer = state.extra as Offer;
|
||||
return MarketOfferDetailScreen(
|
||||
offer: offer,
|
||||
social: social,
|
||||
settings: socialSettings,
|
||||
mine: offer.authorPubkeyHex == social.publicKeyHex,
|
||||
profileCache: profileCache,
|
||||
);
|
||||
},
|
||||
),
|
||||
if (social != null && socialSettings != null && messageStore != null)
|
||||
GoRoute(
|
||||
path: '/messages',
|
||||
|
|
|
|||
|
|
@ -128,6 +128,7 @@ class ShareableLot extends Equatable {
|
|||
required this.offerStatus,
|
||||
this.category,
|
||||
this.harvestYear,
|
||||
this.isOrganic = false,
|
||||
});
|
||||
|
||||
final String lotId;
|
||||
|
|
@ -140,8 +141,13 @@ class ShareableLot extends Equatable {
|
|||
final String? category;
|
||||
final int? harvestYear;
|
||||
|
||||
/// The variety's grower-declared organic ("eco") flag, published so peers can
|
||||
/// filter the market for it.
|
||||
final bool isOrganic;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [lotId, summary, offerStatus, category, harvestYear];
|
||||
List<Object?> get props =>
|
||||
[lotId, summary, offerStatus, category, harvestYear, isOrganic];
|
||||
}
|
||||
|
||||
/// One germination test on a lot; [rate] is derived (0..1), null when it can't
|
||||
|
|
@ -1269,6 +1275,7 @@ class VarietyRepository {
|
|||
offerStatus: l.offerStatus,
|
||||
category: v.category,
|
||||
harvestYear: l.harvestYear,
|
||||
isOrganic: v.isOrganic,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -375,7 +375,11 @@
|
|||
"rangeLabel": "Hasta ónde guetar",
|
||||
"rangeNear": "Bien cerca",
|
||||
"rangeArea": "Pela mio zona",
|
||||
"rangeRegion": "La mio rexón"
|
||||
"rangeRegion": "La mio rexón",
|
||||
"sharedBy": "Compartíu por",
|
||||
"noProfile": "Esta persona entá nun compartió'l so perfil",
|
||||
"copyId": "Copiar códigu",
|
||||
"idCopied": "Códigu copiáu"
|
||||
},
|
||||
"profile": {
|
||||
"title": "El to perfil",
|
||||
|
|
|
|||
|
|
@ -378,7 +378,11 @@
|
|||
"rangeLabel": "How far to look",
|
||||
"rangeNear": "Very close",
|
||||
"rangeArea": "Around here",
|
||||
"rangeRegion": "My region"
|
||||
"rangeRegion": "My region",
|
||||
"sharedBy": "Shared by",
|
||||
"noProfile": "This person hasn't shared a profile yet",
|
||||
"copyId": "Copy code",
|
||||
"idCopied": "Code copied"
|
||||
},
|
||||
"profile": {
|
||||
"title": "Your profile",
|
||||
|
|
|
|||
|
|
@ -377,7 +377,11 @@
|
|||
"rangeLabel": "Hasta dónde buscar",
|
||||
"rangeNear": "Muy cerca",
|
||||
"rangeArea": "Por mi zona",
|
||||
"rangeRegion": "Mi región"
|
||||
"rangeRegion": "Mi región",
|
||||
"sharedBy": "Compartido por",
|
||||
"noProfile": "Esta persona aún no ha compartido su perfil",
|
||||
"copyId": "Copiar código",
|
||||
"idCopied": "Código copiado"
|
||||
},
|
||||
"profile": {
|
||||
"title": "Tu perfil",
|
||||
|
|
|
|||
|
|
@ -374,7 +374,11 @@
|
|||
"rangeLabel": "Até onde procurar",
|
||||
"rangeNear": "Muito perto",
|
||||
"rangeArea": "Pela minha zona",
|
||||
"rangeRegion": "A minha região"
|
||||
"rangeRegion": "A minha região",
|
||||
"sharedBy": "Partilhado por",
|
||||
"noProfile": "Esta pessoa ainda não partilhou o seu perfil",
|
||||
"copyId": "Copiar código",
|
||||
"idCopied": "Código copiado"
|
||||
},
|
||||
"profile": {
|
||||
"title": "O teu perfil",
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@
|
|||
/// To regenerate, run: `dart run slang`
|
||||
///
|
||||
/// Locales: 4
|
||||
/// Strings: 1464 (366 per locale)
|
||||
/// Strings: 1480 (370 per locale)
|
||||
///
|
||||
/// Built on 2026-07-10 at 13:52 UTC
|
||||
/// Built on 2026-07-10 at 15:39 UTC
|
||||
|
||||
// coverage:ignore-file
|
||||
// ignore_for_file: type=lint, unused_import
|
||||
|
|
|
|||
|
|
@ -645,6 +645,15 @@ class _Translations$market$ast extends Translations$market$en {
|
|||
@override String get useLocation => 'Usar el mio allugamientu averáu';
|
||||
@override String get locationFailed => 'Nun se pudo consiguir el to allugamientu — comprueba que l\'allugamientu ta activáu y el permisu concedíu';
|
||||
@override String get queued => 'Guardáu — compartirémosles cuando tengas conexón';
|
||||
@override String get shareFailed => 'Nun se pudo coneutar colos sirvidores — les tos granes nun se compartieron. Vuelvi a intentalo nun momentu.';
|
||||
@override String get rangeLabel => 'Hasta ónde guetar';
|
||||
@override String get rangeNear => 'Bien cerca';
|
||||
@override String get rangeArea => 'Pela mio zona';
|
||||
@override String get rangeRegion => 'La mio rexón';
|
||||
@override String get sharedBy => 'Compartíu por';
|
||||
@override String get noProfile => 'Esta persona entá nun compartió\'l so perfil';
|
||||
@override String get copyId => 'Copiar códigu';
|
||||
@override String get idCopied => 'Códigu copiáu';
|
||||
}
|
||||
|
||||
// Path: profile
|
||||
|
|
@ -1384,6 +1393,15 @@ extension on TranslationsAst {
|
|||
'market.useLocation' => 'Usar el mio allugamientu averáu',
|
||||
'market.locationFailed' => 'Nun se pudo consiguir el to allugamientu — comprueba que l\'allugamientu ta activáu y el permisu concedíu',
|
||||
'market.queued' => 'Guardáu — compartirémosles cuando tengas conexón',
|
||||
'market.shareFailed' => 'Nun se pudo coneutar colos sirvidores — les tos granes nun se compartieron. Vuelvi a intentalo nun momentu.',
|
||||
'market.rangeLabel' => 'Hasta ónde guetar',
|
||||
'market.rangeNear' => 'Bien cerca',
|
||||
'market.rangeArea' => 'Pela mio zona',
|
||||
'market.rangeRegion' => 'La mio rexón',
|
||||
'market.sharedBy' => 'Compartíu por',
|
||||
'market.noProfile' => 'Esta persona entá nun compartió\'l so perfil',
|
||||
'market.copyId' => 'Copiar códigu',
|
||||
'market.idCopied' => 'Códigu copiáu',
|
||||
'profile.title' => 'El to perfil',
|
||||
'profile.name' => 'Nome',
|
||||
'profile.nameHint' => 'Cómo te ven los demás',
|
||||
|
|
|
|||
|
|
@ -1199,6 +1199,18 @@ class Translations$market$en {
|
|||
|
||||
/// en: 'My region'
|
||||
String get rangeRegion => 'My region';
|
||||
|
||||
/// en: 'Shared by'
|
||||
String get sharedBy => 'Shared by';
|
||||
|
||||
/// en: 'This person hasn't shared a profile yet'
|
||||
String get noProfile => 'This person hasn\'t shared a profile yet';
|
||||
|
||||
/// en: 'Copy code'
|
||||
String get copyId => 'Copy code';
|
||||
|
||||
/// en: 'Code copied'
|
||||
String get idCopied => 'Code copied';
|
||||
}
|
||||
|
||||
// Path: profile
|
||||
|
|
@ -2116,6 +2128,10 @@ extension on Translations {
|
|||
'market.rangeNear' => 'Very close',
|
||||
'market.rangeArea' => 'Around here',
|
||||
'market.rangeRegion' => 'My region',
|
||||
'market.sharedBy' => 'Shared by',
|
||||
'market.noProfile' => 'This person hasn\'t shared a profile yet',
|
||||
'market.copyId' => 'Copy code',
|
||||
'market.idCopied' => 'Code copied',
|
||||
'profile.title' => 'Your profile',
|
||||
'profile.name' => 'Display name',
|
||||
'profile.nameHint' => 'How others see you',
|
||||
|
|
|
|||
|
|
@ -652,6 +652,10 @@ class _Translations$market$es extends Translations$market$en {
|
|||
@override String get rangeNear => 'Muy cerca';
|
||||
@override String get rangeArea => 'Por mi zona';
|
||||
@override String get rangeRegion => 'Mi región';
|
||||
@override String get sharedBy => 'Compartido por';
|
||||
@override String get noProfile => 'Esta persona aún no ha compartido su perfil';
|
||||
@override String get copyId => 'Copiar código';
|
||||
@override String get idCopied => 'Código copiado';
|
||||
}
|
||||
|
||||
// Path: profile
|
||||
|
|
@ -1398,6 +1402,10 @@ extension on TranslationsEs {
|
|||
'market.rangeNear' => 'Muy cerca',
|
||||
'market.rangeArea' => 'Por mi zona',
|
||||
'market.rangeRegion' => 'Mi región',
|
||||
'market.sharedBy' => 'Compartido por',
|
||||
'market.noProfile' => 'Esta persona aún no ha compartido su perfil',
|
||||
'market.copyId' => 'Copiar código',
|
||||
'market.idCopied' => 'Código copiado',
|
||||
'profile.title' => 'Tu perfil',
|
||||
'profile.name' => 'Nombre',
|
||||
'profile.nameHint' => 'Cómo te ven los demás',
|
||||
|
|
|
|||
|
|
@ -649,6 +649,10 @@ class _Translations$market$pt extends Translations$market$en {
|
|||
@override String get rangeNear => 'Muito perto';
|
||||
@override String get rangeArea => 'Pela minha zona';
|
||||
@override String get rangeRegion => 'A minha região';
|
||||
@override String get sharedBy => 'Partilhado por';
|
||||
@override String get noProfile => 'Esta pessoa ainda não partilhou o seu perfil';
|
||||
@override String get copyId => 'Copiar código';
|
||||
@override String get idCopied => 'Código copiado';
|
||||
}
|
||||
|
||||
// Path: profile
|
||||
|
|
@ -1392,6 +1396,10 @@ extension on TranslationsPt {
|
|||
'market.rangeNear' => 'Muito perto',
|
||||
'market.rangeArea' => 'Pela minha zona',
|
||||
'market.rangeRegion' => 'A minha região',
|
||||
'market.sharedBy' => 'Partilhado por',
|
||||
'market.noProfile' => 'Esta pessoa ainda não partilhou o seu perfil',
|
||||
'market.copyId' => 'Copiar código',
|
||||
'market.idCopied' => 'Código copiado',
|
||||
'profile.title' => 'O teu perfil',
|
||||
'profile.name' => 'Nome',
|
||||
'profile.nameHint' => 'Como os outros te veem',
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ class OfferMapper {
|
|||
required OfferStatus sharing,
|
||||
required String areaGeohash,
|
||||
String? category,
|
||||
bool isOrganic = false,
|
||||
num? priceAmount,
|
||||
String? priceCurrency,
|
||||
String? exchangeTerms,
|
||||
|
|
@ -43,6 +44,7 @@ class OfferMapper {
|
|||
type: type,
|
||||
approxGeohash: areaGeohash,
|
||||
category: category,
|
||||
isOrganic: isOrganic,
|
||||
priceAmount: type == OfferType.sale ? priceAmount : null,
|
||||
priceCurrency: type == OfferType.sale ? priceCurrency : null,
|
||||
exchangeTerms: type == OfferType.exchange ? exchangeTerms : null,
|
||||
|
|
|
|||
|
|
@ -17,6 +17,9 @@ class OffersState extends Equatable {
|
|||
this.offers = const [],
|
||||
this.areaGeohash = '',
|
||||
this.query = '',
|
||||
this.typeFilter = const {},
|
||||
this.categoryFilter = const {},
|
||||
this.organicOnly = false,
|
||||
this.searching = false,
|
||||
this.publishing = false,
|
||||
this.hasSearched = false,
|
||||
|
|
@ -32,6 +35,15 @@ class OffersState extends Equatable {
|
|||
/// Free-text filter over [offers] typed in the search box (empty = show all).
|
||||
final String query;
|
||||
|
||||
/// Active reciprocity-mode filter (gift/exchange/sale/wanted); empty = all.
|
||||
final Set<OfferType> typeFilter;
|
||||
|
||||
/// Active category filter (free-text categories); empty = all.
|
||||
final Set<String> categoryFilter;
|
||||
|
||||
/// When true, show only offers the grower declared organic ("eco").
|
||||
final bool organicOnly;
|
||||
|
||||
final bool searching;
|
||||
final bool publishing;
|
||||
|
||||
|
|
@ -42,18 +54,47 @@ 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.
|
||||
/// [offers] narrowed by the text [query] and the chip filters (all ANDed).
|
||||
/// Kept separate from [offers] so filtering never drops the 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();
|
||||
return offers.where((o) {
|
||||
if (q.isNotEmpty && !o.summary.toLowerCase().contains(q)) return false;
|
||||
if (typeFilter.isNotEmpty && !typeFilter.contains(o.type)) return false;
|
||||
if (categoryFilter.isNotEmpty &&
|
||||
(o.category == null || !categoryFilter.contains(o.category))) {
|
||||
return false;
|
||||
}
|
||||
if (organicOnly && !o.isOrganic) return false;
|
||||
return true;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/// The distinct categories present across discovered [offers], sorted, so the
|
||||
/// UI only offers a category chip when some offer carries it.
|
||||
List<String> get categories {
|
||||
final set = <String>{
|
||||
for (final o in offers)
|
||||
if (o.category != null && o.category!.isNotEmpty) o.category!,
|
||||
};
|
||||
final list = set.toList()..sort();
|
||||
return list;
|
||||
}
|
||||
|
||||
/// Whether any discovered offer declares organic, so the eco chip only shows
|
||||
/// when it can match something.
|
||||
bool get hasOrganic => offers.any((o) => o.isOrganic);
|
||||
|
||||
bool get hasActiveFilter =>
|
||||
typeFilter.isNotEmpty || categoryFilter.isNotEmpty || organicOnly;
|
||||
|
||||
OffersState copyWith({
|
||||
List<Offer>? offers,
|
||||
String? areaGeohash,
|
||||
String? query,
|
||||
Set<OfferType>? typeFilter,
|
||||
Set<String>? categoryFilter,
|
||||
bool? organicOnly,
|
||||
bool? searching,
|
||||
bool? publishing,
|
||||
bool? hasSearched,
|
||||
|
|
@ -63,6 +104,9 @@ class OffersState extends Equatable {
|
|||
offers: offers ?? this.offers,
|
||||
areaGeohash: areaGeohash ?? this.areaGeohash,
|
||||
query: query ?? this.query,
|
||||
typeFilter: typeFilter ?? this.typeFilter,
|
||||
categoryFilter: categoryFilter ?? this.categoryFilter,
|
||||
organicOnly: organicOnly ?? this.organicOnly,
|
||||
searching: searching ?? this.searching,
|
||||
publishing: publishing ?? this.publishing,
|
||||
hasSearched: hasSearched ?? this.hasSearched,
|
||||
|
|
@ -71,8 +115,18 @@ class OffersState extends Equatable {
|
|||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props =>
|
||||
[offers, areaGeohash, query, searching, publishing, hasSearched, error];
|
||||
List<Object?> get props => [
|
||||
offers,
|
||||
areaGeohash,
|
||||
query,
|
||||
typeFilter,
|
||||
categoryFilter,
|
||||
organicOnly,
|
||||
searching,
|
||||
publishing,
|
||||
hasSearched,
|
||||
error,
|
||||
];
|
||||
}
|
||||
|
||||
/// Drives offer discovery and publishing over an [OfferTransport]. Depends on
|
||||
|
|
@ -106,13 +160,17 @@ class OffersCubit extends Cubit<OffersState> {
|
|||
_searchTimeout?.cancel();
|
||||
emit(OffersState(
|
||||
areaGeohash: geohashPrefix,
|
||||
query: state.query, // keep the text filter across a refresh
|
||||
// Keep the text and chip filters across a refresh (only the results reset).
|
||||
query: state.query,
|
||||
typeFilter: state.typeFilter,
|
||||
categoryFilter: state.categoryFilter,
|
||||
organicOnly: state.organicOnly,
|
||||
searching: true,
|
||||
hasSearched: true,
|
||||
));
|
||||
_sub = transport.discover(DiscoveryQuery(geohashPrefix: geohashPrefix)).listen(
|
||||
(offer) =>
|
||||
emit(state.copyWith(offers: [...state.offers, offer], searching: false)),
|
||||
emit(state.copyWith(offers: _merge(state.offers, offer), searching: false)),
|
||||
onError: (Object e) =>
|
||||
emit(state.copyWith(searching: false, error: () => '$e')),
|
||||
);
|
||||
|
|
@ -126,10 +184,48 @@ class OffersCubit extends Cubit<OffersState> {
|
|||
});
|
||||
}
|
||||
|
||||
/// Appends [incoming] to [current], replacing any existing offer with the same
|
||||
/// author + id. Relays legitimately resend addressable events (a stored copy
|
||||
/// plus a live echo after publishing), so a plain append would double the
|
||||
/// listing; keeping one entry per (author, id) is the NIP-99 semantics.
|
||||
static List<Offer> _merge(List<Offer> current, Offer incoming) => [
|
||||
for (final o in current)
|
||||
if (!(o.id == incoming.id &&
|
||||
o.authorPubkeyHex == incoming.authorPubkeyHex))
|
||||
o,
|
||||
incoming,
|
||||
];
|
||||
|
||||
/// 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));
|
||||
|
||||
/// Toggles a reciprocity-mode chip on/off. Purely local over the discovered
|
||||
/// list; does not re-hit the transport.
|
||||
void toggleType(OfferType type) {
|
||||
final next = {...state.typeFilter};
|
||||
next.contains(type) ? next.remove(type) : next.add(type);
|
||||
emit(state.copyWith(typeFilter: next));
|
||||
}
|
||||
|
||||
/// Toggles a category chip on/off.
|
||||
void toggleCategory(String category) {
|
||||
final next = {...state.categoryFilter};
|
||||
next.contains(category) ? next.remove(category) : next.add(category);
|
||||
emit(state.copyWith(categoryFilter: next));
|
||||
}
|
||||
|
||||
/// Toggles the organic ("eco") filter.
|
||||
void toggleOrganicOnly() =>
|
||||
emit(state.copyWith(organicOnly: !state.organicOnly));
|
||||
|
||||
/// Clears every chip filter (leaves the text search untouched).
|
||||
void clearFilters() => emit(state.copyWith(
|
||||
typeFilter: const {},
|
||||
categoryFilter: const {},
|
||||
organicOnly: false,
|
||||
));
|
||||
|
||||
/// Publishes [offer]; returns the transport's verdict. No-op result when
|
||||
/// offline.
|
||||
Future<PublishResult> publish(Offer offer) async {
|
||||
|
|
@ -172,6 +268,7 @@ class OffersCubit extends Cubit<OffersState> {
|
|||
sharing: lot.offerStatus,
|
||||
areaGeohash: areaGeohash,
|
||||
category: lot.category,
|
||||
isOrganic: lot.isOrganic,
|
||||
);
|
||||
final result = await transport.publish(offer);
|
||||
if (result.accepted) accepted++;
|
||||
|
|
|
|||
307
apps/app_seeds/lib/ui/market_offer_detail_screen.dart
Normal file
307
apps/app_seeds/lib/ui/market_offer_detail_screen.dart
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:commons_core/commons_core.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../i18n/strings.g.dart';
|
||||
import '../services/profile_cache.dart';
|
||||
import '../services/social_service.dart';
|
||||
import '../services/social_settings.dart';
|
||||
import 'market_widgets.dart';
|
||||
import 'theme.dart';
|
||||
|
||||
/// Read-only "product page" for one discovered [Offer] (the Wallapop-style
|
||||
/// detail behind a market card). Shows only what the author elected to publish —
|
||||
/// summary, reciprocity mode, category, coarse area, price, and the eco flag —
|
||||
/// plus who's sharing it and a way to message them. It never fetches inventory
|
||||
/// or an exact address: the privacy seam means the network simply has no more.
|
||||
class MarketOfferDetailScreen extends StatefulWidget {
|
||||
const MarketOfferDetailScreen({
|
||||
required this.offer,
|
||||
required this.social,
|
||||
required this.settings,
|
||||
this.mine = false,
|
||||
this.profileCache,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final Offer offer;
|
||||
final SocialService social;
|
||||
final SocialSettings settings;
|
||||
|
||||
/// Whether this is the current user's own listing (hide the message button).
|
||||
final bool mine;
|
||||
|
||||
/// Optional cache of peers' published display names; null in tests.
|
||||
final ProfileCache? profileCache;
|
||||
|
||||
@override
|
||||
State<MarketOfferDetailScreen> createState() =>
|
||||
_MarketOfferDetailScreenState();
|
||||
}
|
||||
|
||||
class _MarketOfferDetailScreenState extends State<MarketOfferDetailScreen> {
|
||||
SocialSession? _session;
|
||||
String? _sellerName;
|
||||
String? _sellerAbout;
|
||||
String? _sellerG1;
|
||||
bool _profileLoading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadSeller();
|
||||
}
|
||||
|
||||
/// Best-effort fetch of the author's published profile (name/about/Ğ1), the
|
||||
/// same path the chat header uses. Degrades silently when offline.
|
||||
Future<void> _loadSeller() async {
|
||||
final cachedName = await widget.profileCache?.name(widget.offer.authorPubkeyHex);
|
||||
if (mounted && cachedName != null) setState(() => _sellerName = cachedName);
|
||||
|
||||
final relays = await widget.settings.relayUrls();
|
||||
SocialSession? session;
|
||||
if (relays.isNotEmpty) {
|
||||
try {
|
||||
session = await widget.social.openSession(relays);
|
||||
} catch (_) {
|
||||
session = null;
|
||||
}
|
||||
}
|
||||
if (!mounted) {
|
||||
await session?.close();
|
||||
return;
|
||||
}
|
||||
_session = session;
|
||||
if (session == null) {
|
||||
setState(() => _profileLoading = false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final profile = await session.profile.fetch(widget.offer.authorPubkeyHex);
|
||||
if (profile != null && profile.name.isNotEmpty) {
|
||||
unawaited(widget.profileCache
|
||||
?.setName(widget.offer.authorPubkeyHex, profile.name));
|
||||
}
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
if (profile != null && profile.name.isNotEmpty) {
|
||||
_sellerName = profile.name;
|
||||
}
|
||||
_sellerAbout = profile?.about.isNotEmpty == true ? profile!.about : null;
|
||||
_sellerG1 = profile?.g1.isNotEmpty == true ? profile!.g1 : null;
|
||||
_profileLoading = false;
|
||||
});
|
||||
}
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _profileLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_session?.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _copyId() async {
|
||||
final t = context.t;
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
await Clipboard.setData(
|
||||
ClipboardData(text: widget.offer.authorPubkeyHex));
|
||||
messenger.showSnackBar(SnackBar(content: Text(t.market.idCopied)));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = context.t;
|
||||
final o = widget.offer;
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(o.summary)),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
o.summary,
|
||||
style: const TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: seedTitle,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
OfferTypeChip(label: offerTypeLabel(t, o.type)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
if (o.isOrganic)
|
||||
_Badge(
|
||||
icon: Icons.eco,
|
||||
label: t.editVariety.organic,
|
||||
),
|
||||
if (o.category != null && o.category!.isNotEmpty)
|
||||
_Badge(icon: Icons.folder_outlined, label: o.category!),
|
||||
_Badge(icon: Icons.place_outlined, label: t.market.near),
|
||||
],
|
||||
),
|
||||
if (o.type == OfferType.sale && o.priceAmount != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'${o.priceAmount} ${o.priceCurrency ?? ''}'.trim(),
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: seedOnSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (o.type == OfferType.exchange &&
|
||||
o.exchangeTerms != null &&
|
||||
o.exchangeTerms!.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
o.exchangeTerms!,
|
||||
style: const TextStyle(color: seedOnSurface, fontSize: 15),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
const Divider(),
|
||||
const SizedBox(height: 12),
|
||||
_SellerSection(
|
||||
title: t.market.sharedBy,
|
||||
name: _sellerName ?? shortPubkey(o.authorPubkeyHex),
|
||||
about: _sellerAbout,
|
||||
loading: _profileLoading,
|
||||
hasProfile:
|
||||
_sellerName != null || _sellerAbout != null || _sellerG1 != null,
|
||||
noProfileLabel: t.market.noProfile,
|
||||
onCopyId: _copyId,
|
||||
copyIdTooltip: t.market.copyId,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
if (!widget.mine)
|
||||
FilledButton.icon(
|
||||
key: const Key('offerDetail.contact'),
|
||||
onPressed: () => context.push('/chat/${o.authorPubkeyHex}'),
|
||||
icon: const Icon(Icons.chat_bubble_outline),
|
||||
label: Text(t.market.contact),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A small pill with an icon + label (eco, category, "near you").
|
||||
class _Badge extends StatelessWidget {
|
||||
const _Badge({required this.icon, required this.label});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: seedPrimaryContainer.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 15, color: seedGreen),
|
||||
const SizedBox(width: 5),
|
||||
Text(label,
|
||||
style: const TextStyle(color: seedOnSurface, fontSize: 13)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// "Shared by" card: who's offering this, their bio, and a copy-code affordance.
|
||||
class _SellerSection extends StatelessWidget {
|
||||
const _SellerSection({
|
||||
required this.title,
|
||||
required this.name,
|
||||
required this.about,
|
||||
required this.loading,
|
||||
required this.hasProfile,
|
||||
required this.noProfileLabel,
|
||||
required this.onCopyId,
|
||||
required this.copyIdTooltip,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String name;
|
||||
final String? about;
|
||||
final bool loading;
|
||||
final bool hasProfile;
|
||||
final String noProfileLabel;
|
||||
final VoidCallback onCopyId;
|
||||
final String copyIdTooltip;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: seedMuted,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
const CircleAvatar(
|
||||
radius: 20,
|
||||
backgroundColor: seedPrimaryContainer,
|
||||
child: Icon(Icons.person_outline, color: seedGreen),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
name,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: seedOnSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
key: const Key('offerDetail.copyId'),
|
||||
icon: const Icon(Icons.copy, size: 18, color: seedMuted),
|
||||
tooltip: copyIdTooltip,
|
||||
onPressed: onCopyId,
|
||||
),
|
||||
],
|
||||
),
|
||||
if (about != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(about!,
|
||||
style: const TextStyle(color: seedOnSurface, fontSize: 14)),
|
||||
] else if (!loading && !hasProfile) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(noProfileLabel,
|
||||
style: const TextStyle(color: seedMuted, fontSize: 13)),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ import '../services/offer_outbox.dart';
|
|||
import '../services/social_service.dart';
|
||||
import '../services/social_settings.dart';
|
||||
import '../state/offers_cubit.dart';
|
||||
import 'market_widgets.dart';
|
||||
import 'theme.dart';
|
||||
|
||||
/// The market (redesign screens 04/05): discover seeds shared near you. Opens
|
||||
|
|
@ -300,6 +301,7 @@ class MarketBody extends StatelessWidget {
|
|||
onChanged: cubit.search,
|
||||
),
|
||||
),
|
||||
_MarketFilterBar(state: state),
|
||||
Expanded(
|
||||
child: RefreshIndicator(
|
||||
onRefresh: refresh,
|
||||
|
|
@ -329,10 +331,8 @@ class MarketBody extends StatelessWidget {
|
|||
return _OfferCard(
|
||||
offer: o,
|
||||
mine: mine,
|
||||
onContact: mine
|
||||
? null
|
||||
: () =>
|
||||
context.push('/chat/${o.authorPubkeyHex}'),
|
||||
onTap: () =>
|
||||
context.push('/market/offer', extra: o),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
|
@ -348,119 +348,177 @@ class MarketBody extends StatelessWidget {
|
|||
/// One discovered offer. Human words for the reciprocity mode; coarse "near you"
|
||||
/// instead of any precise location; price only when it's a sale.
|
||||
class _OfferCard extends StatelessWidget {
|
||||
const _OfferCard({required this.offer, this.mine = false, this.onContact});
|
||||
const _OfferCard({required this.offer, this.mine = false, this.onTap});
|
||||
|
||||
final Offer offer;
|
||||
|
||||
/// Whether this is the current user's own listing (shown with a "You" badge).
|
||||
final bool mine;
|
||||
|
||||
/// Opens a private chat with the author; null for our own listings.
|
||||
final VoidCallback? onContact;
|
||||
/// Opens the offer's detail ("product") page.
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = context.t;
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: seedOutline),
|
||||
),
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
final radius = BorderRadius.circular(14);
|
||||
return Material(
|
||||
color: Colors.white,
|
||||
borderRadius: radius,
|
||||
child: InkWell(
|
||||
key: Key('market.offer.${offer.authorPubkeyHex}.${offer.id}'),
|
||||
onTap: onTap,
|
||||
borderRadius: radius,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: radius,
|
||||
border: Border.all(color: seedOutline),
|
||||
),
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
offer.summary,
|
||||
style: const TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: seedOnSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (mine) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: seedGreen,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
t.market.mine,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
offer.summary,
|
||||
style: const TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: seedOnSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
],
|
||||
_TypeChip(label: _offerTypeLabel(t, offer.type)),
|
||||
if (mine) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: seedGreen,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
t.market.mine,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
],
|
||||
OfferTypeChip(label: offerTypeLabel(t, offer.type)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.place_outlined, size: 15, color: seedMuted),
|
||||
const SizedBox(width: 4),
|
||||
Text(t.market.near,
|
||||
style: const TextStyle(color: seedMuted, fontSize: 13)),
|
||||
if (offer.isOrganic) ...[
|
||||
const SizedBox(width: 10),
|
||||
const Icon(Icons.eco, size: 15, color: seedGreen),
|
||||
],
|
||||
const Spacer(),
|
||||
if (offer.type == OfferType.sale && offer.priceAmount != null)
|
||||
Text(
|
||||
'${offer.priceAmount} ${offer.priceCurrency ?? ''}'.trim(),
|
||||
style: const TextStyle(
|
||||
color: seedOnSurface,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
const Icon(Icons.chevron_right, size: 18, color: seedMuted),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.place_outlined, size: 15, color: seedMuted),
|
||||
const SizedBox(width: 4),
|
||||
Text(t.market.near, style: const TextStyle(color: seedMuted, fontSize: 13)),
|
||||
const Spacer(),
|
||||
if (offer.type == OfferType.sale && offer.priceAmount != null)
|
||||
Text(
|
||||
'${offer.priceAmount} ${offer.priceCurrency ?? ''}'.trim(),
|
||||
style: const TextStyle(
|
||||
color: seedOnSurface,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
if (onContact != null)
|
||||
TextButton.icon(
|
||||
onPressed: onContact,
|
||||
icon: const Icon(Icons.chat_bubble_outline, size: 16),
|
||||
label: Text(t.market.contact),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _offerTypeLabel(Translations t, OfferType type) => switch (type) {
|
||||
OfferType.gift => t.share.gift,
|
||||
OfferType.exchange => t.share.exchange,
|
||||
OfferType.sale => t.share.sell,
|
||||
OfferType.wanted => t.market.wanted,
|
||||
OfferType.lend => t.share.exchange,
|
||||
};
|
||||
/// Optional filters over the discovered offers, mirroring the inventory filter
|
||||
/// bar: reciprocity mode (give/swap/sell/wanted), category, and the eco flag.
|
||||
/// Each chip group only appears when some discovered offer can match it.
|
||||
class _MarketFilterBar extends StatelessWidget {
|
||||
const _MarketFilterBar({required this.state});
|
||||
|
||||
class _TypeChip extends StatelessWidget {
|
||||
const _TypeChip({required this.label});
|
||||
|
||||
final String label;
|
||||
final OffersState state;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: seedPrimaryContainer,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
color: seedOnPrimaryContainer,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
final t = context.t;
|
||||
final cubit = context.read<OffersCubit>();
|
||||
final categories = state.categories;
|
||||
// Only offer type chips for modes some discovered offer actually holds.
|
||||
final types = <OfferType>[
|
||||
for (final type in const [
|
||||
OfferType.gift,
|
||||
OfferType.exchange,
|
||||
OfferType.sale,
|
||||
OfferType.wanted,
|
||||
])
|
||||
if (state.offers.any((o) => o.type == type)) type,
|
||||
];
|
||||
final hasOrganic = state.hasOrganic;
|
||||
if (categories.isEmpty && types.isEmpty && !hasOrganic) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final chips = <Widget>[
|
||||
if (hasOrganic)
|
||||
FilterChip(
|
||||
key: const Key('market.filter.organic'),
|
||||
avatar: Icon(
|
||||
Icons.eco,
|
||||
size: 18,
|
||||
color: state.organicOnly ? null : seedGreen,
|
||||
),
|
||||
label: Text(t.editVariety.organic),
|
||||
selected: state.organicOnly,
|
||||
onSelected: (_) => cubit.toggleOrganicOnly(),
|
||||
),
|
||||
for (final type in types)
|
||||
FilterChip(
|
||||
key: Key('market.filter.type.${type.name}'),
|
||||
label: Text(offerTypeLabel(t, type)),
|
||||
selected: state.typeFilter.contains(type),
|
||||
onSelected: (_) => cubit.toggleType(type),
|
||||
),
|
||||
for (final category in categories)
|
||||
FilterChip(
|
||||
key: Key('market.filter.category.$category'),
|
||||
label: Text(category),
|
||||
selected: state.categoryFilter.contains(category),
|
||||
onSelected: (_) => cubit.toggleCategory(category),
|
||||
),
|
||||
];
|
||||
|
||||
return SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
for (final chip in chips)
|
||||
Padding(
|
||||
padding: const EdgeInsetsDirectional.only(end: 8),
|
||||
child: chip,
|
||||
),
|
||||
if (state.hasActiveFilter)
|
||||
TextButton.icon(
|
||||
key: const Key('market.filter.clear'),
|
||||
onPressed: cubit.clearFilters,
|
||||
icon: const Icon(Icons.clear, size: 18),
|
||||
label: Text(t.inventory.clearFilters),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
41
apps/app_seeds/lib/ui/market_widgets.dart
Normal file
41
apps/app_seeds/lib/ui/market_widgets.dart
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import 'package:commons_core/commons_core.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../i18n/strings.g.dart';
|
||||
import 'theme.dart';
|
||||
|
||||
/// Human words for an offer's reciprocity mode. Shared by the market list and
|
||||
/// the offer detail page so both read the same way.
|
||||
String offerTypeLabel(Translations t, OfferType type) => switch (type) {
|
||||
OfferType.gift => t.share.gift,
|
||||
OfferType.exchange => t.share.exchange,
|
||||
OfferType.sale => t.share.sell,
|
||||
OfferType.wanted => t.market.wanted,
|
||||
OfferType.lend => t.share.exchange,
|
||||
};
|
||||
|
||||
/// The small pill showing an offer's reciprocity mode (give/swap/sell/wanted).
|
||||
class OfferTypeChip extends StatelessWidget {
|
||||
const OfferTypeChip({required this.label, super.key});
|
||||
|
||||
final String label;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: seedPrimaryContainer,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
color: seedOnPrimaryContainer,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -40,6 +40,38 @@ class FakeOfferTransport implements OfferTransport {
|
|||
Future<void> close() async {}
|
||||
}
|
||||
|
||||
/// Emits every published offer TWICE on discover — the way a relay legitimately
|
||||
/// resends an addressable event (a stored copy plus a live echo). Used to prove
|
||||
/// the cubit dedups instead of doubling the listing.
|
||||
class DuplicatingOfferTransport implements OfferTransport {
|
||||
final List<Offer> _offers = [];
|
||||
|
||||
@override
|
||||
Future<PublishResult> publish(Offer offer) async {
|
||||
_offers.add(offer);
|
||||
return PublishResult(accepted: true, transportRef: offer.id);
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<Offer> discover(DiscoveryQuery query) {
|
||||
final controller = StreamController<Offer>();
|
||||
for (final o in _offers) {
|
||||
if (o.approxGeohash.startsWith(query.geohashPrefix)) {
|
||||
controller.add(o);
|
||||
controller.add(o); // the duplicate
|
||||
}
|
||||
}
|
||||
return controller.stream;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> retract(String offerId) async =>
|
||||
_offers.removeWhere((o) => o.id == offerId);
|
||||
|
||||
@override
|
||||
Future<void> close() async {}
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('OfferMapper', () {
|
||||
test('maps local sharing intent to the network offer type', () {
|
||||
|
|
@ -79,6 +111,26 @@ void main() {
|
|||
expect(sale.priceAmount, 3);
|
||||
expect(sale.priceCurrency, 'G1');
|
||||
});
|
||||
|
||||
test('carries the organic (eco) flag through to the offer', () {
|
||||
final eco = OfferMapper.fromSharedLot(
|
||||
lotId: 'lot-1',
|
||||
authorPubkeyHex: 'ab' * 32,
|
||||
summary: 'Lechuga',
|
||||
sharing: OfferStatus.shared,
|
||||
areaGeohash: 'sp3e9',
|
||||
isOrganic: true,
|
||||
);
|
||||
expect(eco.isOrganic, isTrue);
|
||||
final plain = OfferMapper.fromSharedLot(
|
||||
lotId: 'lot-2',
|
||||
authorPubkeyHex: 'ab' * 32,
|
||||
summary: 'Lechuga',
|
||||
sharing: OfferStatus.shared,
|
||||
areaGeohash: 'sp3e9',
|
||||
);
|
||||
expect(plain.isOrganic, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('OffersCubit', () {
|
||||
|
|
@ -347,5 +399,117 @@ void main() {
|
|||
expect(result.accepted, isFalse);
|
||||
await cubit.close();
|
||||
});
|
||||
|
||||
test('a resent offer is not duplicated in the list', () async {
|
||||
// Regression: publishing an inventory lot showed up twice because the
|
||||
// relay resends the addressable event; the cubit must dedup by author+id.
|
||||
final transport = DuplicatingOfferTransport();
|
||||
await transport.publish(OfferMapper.fromSharedLot(
|
||||
lotId: 'lot-1',
|
||||
authorPubkeyHex: 'ab' * 32,
|
||||
summary: 'Tomate rosa',
|
||||
sharing: OfferStatus.shared,
|
||||
areaGeohash: 'sp3e9',
|
||||
));
|
||||
final cubit = OffersCubit(transport);
|
||||
await cubit.discover('sp3');
|
||||
await pumpEventQueue();
|
||||
expect(cubit.state.offers, hasLength(1));
|
||||
await cubit.close();
|
||||
});
|
||||
|
||||
test('two authors reusing the same lot id are kept apart', () async {
|
||||
// A non-deduping transport, so both authors' same-id offers reach discover.
|
||||
final transport = DuplicatingOfferTransport();
|
||||
for (final author in ['ab' * 32, 'cd' * 32]) {
|
||||
await transport.publish(Offer(
|
||||
id: 'lot-1', // same d-tag, different author
|
||||
authorPubkeyHex: author,
|
||||
summary: 'Tomate',
|
||||
type: OfferType.gift,
|
||||
approxGeohash: 'sp3e9',
|
||||
));
|
||||
}
|
||||
final cubit = OffersCubit(transport);
|
||||
await cubit.discover('sp3');
|
||||
await pumpEventQueue();
|
||||
expect(cubit.state.offers, hasLength(2));
|
||||
await cubit.close();
|
||||
});
|
||||
});
|
||||
|
||||
group('OffersCubit filters', () {
|
||||
Offer offer(String id, OfferType type,
|
||||
{String? category, bool organic = false}) =>
|
||||
Offer(
|
||||
id: id,
|
||||
authorPubkeyHex: 'ab' * 32,
|
||||
summary: id,
|
||||
type: type,
|
||||
category: category,
|
||||
approxGeohash: 'sp3e9',
|
||||
isOrganic: organic,
|
||||
);
|
||||
|
||||
Future<OffersCubit> seeded(List<Offer> offers) async {
|
||||
final transport = FakeOfferTransport();
|
||||
for (final o in offers) {
|
||||
await transport.publish(o);
|
||||
}
|
||||
final cubit = OffersCubit(transport);
|
||||
await cubit.discover('sp3');
|
||||
await pumpEventQueue();
|
||||
return cubit;
|
||||
}
|
||||
|
||||
test('type, category and eco chips narrow the list (ANDed with search)',
|
||||
() async {
|
||||
final cubit = await seeded([
|
||||
offer('tomate-eco', OfferType.gift, category: 'Solanaceae', organic: true),
|
||||
offer('tomate-sale', OfferType.sale, category: 'Solanaceae'),
|
||||
offer('judia-eco', OfferType.gift, category: 'Fabaceae', organic: true),
|
||||
]);
|
||||
|
||||
cubit.toggleType(OfferType.gift);
|
||||
expect(cubit.state.visibleOffers.map((o) => o.id),
|
||||
containsAll(['tomate-eco', 'judia-eco']));
|
||||
expect(cubit.state.visibleOffers, hasLength(2));
|
||||
|
||||
cubit.toggleCategory('Solanaceae');
|
||||
expect(cubit.state.visibleOffers.single.id, 'tomate-eco');
|
||||
|
||||
cubit.toggleOrganicOnly();
|
||||
expect(cubit.state.visibleOffers.single.id, 'tomate-eco');
|
||||
|
||||
cubit.clearFilters();
|
||||
expect(cubit.state.visibleOffers, hasLength(3));
|
||||
expect(cubit.state.hasActiveFilter, isFalse);
|
||||
await cubit.close();
|
||||
});
|
||||
|
||||
test('exposes categories and hasOrganic from the discovered offers',
|
||||
() async {
|
||||
final cubit = await seeded([
|
||||
offer('a', OfferType.gift, category: 'Solanaceae', organic: true),
|
||||
offer('b', OfferType.gift, category: 'Fabaceae'),
|
||||
offer('c', OfferType.gift),
|
||||
]);
|
||||
expect(cubit.state.categories, ['Fabaceae', 'Solanaceae']); // sorted
|
||||
expect(cubit.state.hasOrganic, isTrue);
|
||||
await cubit.close();
|
||||
});
|
||||
|
||||
test('chip filters survive a refresh (re-discovery)', () async {
|
||||
final cubit = await seeded([
|
||||
offer('gift', OfferType.gift),
|
||||
offer('sale', OfferType.sale),
|
||||
]);
|
||||
cubit.toggleType(OfferType.gift);
|
||||
await cubit.discover('sp3'); // pull-to-refresh
|
||||
await pumpEventQueue();
|
||||
expect(cubit.state.typeFilter, {OfferType.gift});
|
||||
expect(cubit.state.visibleOffers.single.id, 'gift');
|
||||
await cubit.close();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
73
apps/app_seeds/test/ui/market_offer_detail_screen_test.dart
Normal file
73
apps/app_seeds/test/ui/market_offer_detail_screen_test.dart
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import 'package:commons_core/commons_core.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:tane/i18n/strings.g.dart';
|
||||
import 'package:tane/services/social_service.dart';
|
||||
import 'package:tane/services/social_settings.dart';
|
||||
import 'package:tane/ui/market_offer_detail_screen.dart';
|
||||
|
||||
import '../support/test_support.dart';
|
||||
|
||||
Widget _wrap(MarketOfferDetailScreen screen) {
|
||||
LocaleSettings.setLocaleSync(AppLocale.en);
|
||||
return TranslationProvider(
|
||||
child: MaterialApp(
|
||||
locale: AppLocale.en.flutterLocale,
|
||||
supportedLocales: AppLocaleUtils.supportedLocales,
|
||||
localizationsDelegates: const [
|
||||
GlobalMaterialLocalizations.delegate,
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
],
|
||||
home: screen,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Offer _offer({bool organic = false}) => Offer(
|
||||
id: 'lot-1',
|
||||
authorPubkeyHex: 'ab' * 32,
|
||||
summary: 'Tomate rosa de Barbastro',
|
||||
type: OfferType.gift,
|
||||
category: 'Solanaceae',
|
||||
approxGeohash: 'sp3e9',
|
||||
isOrganic: organic,
|
||||
);
|
||||
|
||||
void main() {
|
||||
testWidgets('renders the offer and a message button for a stranger',
|
||||
(tester) async {
|
||||
final social = await SocialService.fromRootSeedHex('00' * 32);
|
||||
final settings = SocialSettings(InMemorySecretStore());
|
||||
await settings.setRelayUrls(const []); // offline: no network in the test
|
||||
|
||||
await tester.pumpWidget(_wrap(MarketOfferDetailScreen(
|
||||
offer: _offer(organic: true),
|
||||
social: social,
|
||||
settings: settings,
|
||||
)));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Tomate rosa de Barbastro'), findsWidgets);
|
||||
expect(find.text('Solanaceae'), findsOneWidget);
|
||||
expect(find.text('Organic'), findsOneWidget); // eco badge
|
||||
expect(find.byKey(const Key('offerDetail.contact')), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('hides the message button on my own listing', (tester) async {
|
||||
final social = await SocialService.fromRootSeedHex('00' * 32);
|
||||
final settings = SocialSettings(InMemorySecretStore());
|
||||
await settings.setRelayUrls(const []);
|
||||
|
||||
await tester.pumpWidget(_wrap(MarketOfferDetailScreen(
|
||||
offer: _offer(),
|
||||
social: social,
|
||||
settings: settings,
|
||||
mine: true,
|
||||
)));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const Key('offerDetail.contact')), findsNothing);
|
||||
});
|
||||
}
|
||||
|
|
@ -30,6 +30,7 @@ class Nip99Codec {
|
|||
tags.add(['g', coarse.substring(0, i)]); // NIP-52 prefix ladder, coarse only
|
||||
}
|
||||
if (offer.category != null) tags.add(['t', offer.category!]);
|
||||
if (offer.isOrganic) tags.add(['organic', '1']);
|
||||
if (offer.radiusKm != null) tags.add(['radius_km', '${offer.radiusKm}']);
|
||||
if (offer.type == OfferType.sale && offer.priceAmount != null) {
|
||||
tags.add(['price', '${offer.priceAmount}', offer.priceCurrency ?? 'EUR']);
|
||||
|
|
@ -62,6 +63,7 @@ class Nip99Codec {
|
|||
priceCurrency: price != null && price.length > 2 ? price[2] : null,
|
||||
exchangeTerms: _tagValue(event, 'exchange_terms'),
|
||||
imageUrl: _tagValue(event, 'image'),
|
||||
isOrganic: _tagValue(event, 'organic') == '1',
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ class Offer {
|
|||
this.radiusKm,
|
||||
this.imageUrl,
|
||||
this.expiresAt,
|
||||
this.isOrganic = false,
|
||||
});
|
||||
|
||||
/// Stable per-offer identifier (NIP-99 `d` tag → addressable event).
|
||||
|
|
@ -57,6 +58,10 @@ class Offer {
|
|||
|
||||
final String? imageUrl;
|
||||
final DateTime? expiresAt;
|
||||
|
||||
/// Grower-declared organic ("eco") provenance — a self-declaration the author
|
||||
/// chose to publish, so peers can filter for it. Never a certification.
|
||||
final bool isOrganic;
|
||||
}
|
||||
|
||||
/// Result of publishing to a transport.
|
||||
|
|
|
|||
|
|
@ -77,6 +77,43 @@ void main() {
|
|||
await bobT.close();
|
||||
});
|
||||
|
||||
test('the organic (eco) flag round-trips; absent when not declared',
|
||||
() async {
|
||||
final alice = await idFor(1);
|
||||
final bob = await idFor(2);
|
||||
final aliceT = NostrOfferTransport(await connFor(alice));
|
||||
final bobT = NostrOfferTransport(await connFor(bob));
|
||||
|
||||
await aliceT.publish(Offer(
|
||||
id: 'eco-1',
|
||||
authorPubkeyHex: alice.publicKeyHex,
|
||||
summary: 'Lechuga eco',
|
||||
type: OfferType.gift,
|
||||
approxGeohash: 'sp3e9',
|
||||
isOrganic: true,
|
||||
));
|
||||
await aliceT.publish(Offer(
|
||||
id: 'plain-1',
|
||||
authorPubkeyHex: alice.publicKeyHex,
|
||||
summary: 'Lechuga',
|
||||
type: OfferType.gift,
|
||||
approxGeohash: 'sp3e9',
|
||||
));
|
||||
|
||||
final found = await bobT
|
||||
.discoverUntilEose(const DiscoveryQuery(geohashPrefix: 'sp3'));
|
||||
final eco = found.firstWhere((o) => o.id == 'eco-1');
|
||||
final plain = found.firstWhere((o) => o.id == 'plain-1');
|
||||
expect(eco.isOrganic, isTrue);
|
||||
expect(plain.isOrganic, isFalse);
|
||||
// A plain listing carries no organic tag at all.
|
||||
final wire = jsonEncode(relay.eventsOfKind(30402));
|
||||
expect('organic'.allMatches(wire), hasLength(1));
|
||||
|
||||
await aliceT.close();
|
||||
await bobT.close();
|
||||
});
|
||||
|
||||
test('exact geohash never leaves the device (coarsened on the wire)',
|
||||
() async {
|
||||
final alice = await idFor(1);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue