Merge branch 'claude/intelligent-bardeen-0d5ee9': market detail page, filters, publish-dedup
# Conflicts: # apps/app_seeds/lib/i18n/strings.g.dart # apps/app_seeds/lib/i18n/strings_ast.g.dart
This commit is contained in:
commit
a71493bfb1
21 changed files with 972 additions and 107 deletions
|
|
@ -1,3 +1,4 @@
|
||||||
|
import 'package:commons_core/commons_core.dart';
|
||||||
import 'package:flutter/gestures.dart';
|
import 'package:flutter/gestures.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
|
@ -26,6 +27,7 @@ import 'ui/chat_screen.dart';
|
||||||
import 'ui/home_screen.dart';
|
import 'ui/home_screen.dart';
|
||||||
import 'ui/intro_screen.dart';
|
import 'ui/intro_screen.dart';
|
||||||
import 'ui/inventory_list_screen.dart';
|
import 'ui/inventory_list_screen.dart';
|
||||||
|
import 'ui/market_offer_detail_screen.dart';
|
||||||
import 'ui/market_screen.dart';
|
import 'ui/market_screen.dart';
|
||||||
import 'ui/offline_banner.dart';
|
import 'ui/offline_banner.dart';
|
||||||
import 'ui/profile_screen.dart';
|
import 'ui/profile_screen.dart';
|
||||||
|
|
@ -130,6 +132,20 @@ class TaneApp extends StatelessWidget {
|
||||||
outbox: outbox,
|
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)
|
if (social != null && socialSettings != null && messageStore != null)
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/messages',
|
path: '/messages',
|
||||||
|
|
|
||||||
|
|
@ -128,6 +128,7 @@ class ShareableLot extends Equatable {
|
||||||
required this.offerStatus,
|
required this.offerStatus,
|
||||||
this.category,
|
this.category,
|
||||||
this.harvestYear,
|
this.harvestYear,
|
||||||
|
this.isOrganic = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String lotId;
|
final String lotId;
|
||||||
|
|
@ -140,8 +141,13 @@ class ShareableLot extends Equatable {
|
||||||
final String? category;
|
final String? category;
|
||||||
final int? harvestYear;
|
final int? harvestYear;
|
||||||
|
|
||||||
|
/// The variety's grower-declared organic ("eco") flag, published so peers can
|
||||||
|
/// filter the market for it.
|
||||||
|
final bool isOrganic;
|
||||||
|
|
||||||
@override
|
@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
|
/// 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,
|
offerStatus: l.offerStatus,
|
||||||
category: v.category,
|
category: v.category,
|
||||||
harvestYear: l.harvestYear,
|
harvestYear: l.harvestYear,
|
||||||
|
isOrganic: v.isOrganic,
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -379,7 +379,11 @@
|
||||||
"rangeLabel": "Hasta ónde guetar",
|
"rangeLabel": "Hasta ónde guetar",
|
||||||
"rangeNear": "Bien cerca",
|
"rangeNear": "Bien cerca",
|
||||||
"rangeArea": "Pela mio zona",
|
"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": {
|
"profile": {
|
||||||
"title": "El to perfil",
|
"title": "El to perfil",
|
||||||
|
|
|
||||||
|
|
@ -382,7 +382,11 @@
|
||||||
"rangeLabel": "How far to look",
|
"rangeLabel": "How far to look",
|
||||||
"rangeNear": "Very close",
|
"rangeNear": "Very close",
|
||||||
"rangeArea": "Around here",
|
"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": {
|
"profile": {
|
||||||
"title": "Your profile",
|
"title": "Your profile",
|
||||||
|
|
|
||||||
|
|
@ -381,7 +381,11 @@
|
||||||
"rangeLabel": "Hasta dónde buscar",
|
"rangeLabel": "Hasta dónde buscar",
|
||||||
"rangeNear": "Muy cerca",
|
"rangeNear": "Muy cerca",
|
||||||
"rangeArea": "Por mi zona",
|
"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": {
|
"profile": {
|
||||||
"title": "Tu perfil",
|
"title": "Tu perfil",
|
||||||
|
|
|
||||||
|
|
@ -378,7 +378,11 @@
|
||||||
"rangeLabel": "Até onde procurar",
|
"rangeLabel": "Até onde procurar",
|
||||||
"rangeNear": "Muito perto",
|
"rangeNear": "Muito perto",
|
||||||
"rangeArea": "Pela minha zona",
|
"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": {
|
"profile": {
|
||||||
"title": "O teu perfil",
|
"title": "O teu perfil",
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,9 @@
|
||||||
/// To regenerate, run: `dart run slang`
|
/// To regenerate, run: `dart run slang`
|
||||||
///
|
///
|
||||||
/// Locales: 4
|
/// Locales: 4
|
||||||
/// Strings: 1472 (368 per locale)
|
/// Strings: 1488 (372 per locale)
|
||||||
///
|
///
|
||||||
/// Built on 2026-07-10 at 15:00 UTC
|
/// Built on 2026-07-10 at 17:11 UTC
|
||||||
|
|
||||||
// coverage:ignore-file
|
// coverage:ignore-file
|
||||||
// ignore_for_file: type=lint, unused_import
|
// ignore_for_file: type=lint, unused_import
|
||||||
|
|
|
||||||
|
|
@ -662,6 +662,10 @@ class _Translations$market$ast extends Translations$market$en {
|
||||||
@override String get rangeNear => 'Bien cerca';
|
@override String get rangeNear => 'Bien cerca';
|
||||||
@override String get rangeArea => 'Pela mio zona';
|
@override String get rangeArea => 'Pela mio zona';
|
||||||
@override String get rangeRegion => 'La mio rexón';
|
@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
|
// Path: profile
|
||||||
|
|
@ -1408,6 +1412,10 @@ extension on TranslationsAst {
|
||||||
'market.rangeNear' => 'Bien cerca',
|
'market.rangeNear' => 'Bien cerca',
|
||||||
'market.rangeArea' => 'Pela mio zona',
|
'market.rangeArea' => 'Pela mio zona',
|
||||||
'market.rangeRegion' => 'La mio rexón',
|
'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.title' => 'El to perfil',
|
||||||
'profile.name' => 'Nome',
|
'profile.name' => 'Nome',
|
||||||
'profile.nameHint' => 'Cómo te ven los demás',
|
'profile.nameHint' => 'Cómo te ven los demás',
|
||||||
|
|
|
||||||
|
|
@ -1215,6 +1215,18 @@ class Translations$market$en {
|
||||||
|
|
||||||
/// en: 'My region'
|
/// en: 'My region'
|
||||||
String get rangeRegion => '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
|
// Path: profile
|
||||||
|
|
@ -2134,6 +2146,10 @@ extension on Translations {
|
||||||
'market.rangeNear' => 'Very close',
|
'market.rangeNear' => 'Very close',
|
||||||
'market.rangeArea' => 'Around here',
|
'market.rangeArea' => 'Around here',
|
||||||
'market.rangeRegion' => 'My region',
|
'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.title' => 'Your profile',
|
||||||
'profile.name' => 'Display name',
|
'profile.name' => 'Display name',
|
||||||
'profile.nameHint' => 'How others see you',
|
'profile.nameHint' => 'How others see you',
|
||||||
|
|
|
||||||
|
|
@ -664,6 +664,10 @@ class _Translations$market$es extends Translations$market$en {
|
||||||
@override String get rangeNear => 'Muy cerca';
|
@override String get rangeNear => 'Muy cerca';
|
||||||
@override String get rangeArea => 'Por mi zona';
|
@override String get rangeArea => 'Por mi zona';
|
||||||
@override String get rangeRegion => 'Mi región';
|
@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
|
// Path: profile
|
||||||
|
|
@ -1412,6 +1416,10 @@ extension on TranslationsEs {
|
||||||
'market.rangeNear' => 'Muy cerca',
|
'market.rangeNear' => 'Muy cerca',
|
||||||
'market.rangeArea' => 'Por mi zona',
|
'market.rangeArea' => 'Por mi zona',
|
||||||
'market.rangeRegion' => 'Mi región',
|
'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.title' => 'Tu perfil',
|
||||||
'profile.name' => 'Nombre',
|
'profile.name' => 'Nombre',
|
||||||
'profile.nameHint' => 'Cómo te ven los demás',
|
'profile.nameHint' => 'Cómo te ven los demás',
|
||||||
|
|
|
||||||
|
|
@ -661,6 +661,10 @@ class _Translations$market$pt extends Translations$market$en {
|
||||||
@override String get rangeNear => 'Muito perto';
|
@override String get rangeNear => 'Muito perto';
|
||||||
@override String get rangeArea => 'Pela minha zona';
|
@override String get rangeArea => 'Pela minha zona';
|
||||||
@override String get rangeRegion => 'A minha região';
|
@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
|
// Path: profile
|
||||||
|
|
@ -1406,6 +1410,10 @@ extension on TranslationsPt {
|
||||||
'market.rangeNear' => 'Muito perto',
|
'market.rangeNear' => 'Muito perto',
|
||||||
'market.rangeArea' => 'Pela minha zona',
|
'market.rangeArea' => 'Pela minha zona',
|
||||||
'market.rangeRegion' => 'A minha região',
|
'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.title' => 'O teu perfil',
|
||||||
'profile.name' => 'Nome',
|
'profile.name' => 'Nome',
|
||||||
'profile.nameHint' => 'Como os outros te veem',
|
'profile.nameHint' => 'Como os outros te veem',
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ class OfferMapper {
|
||||||
required OfferStatus sharing,
|
required OfferStatus sharing,
|
||||||
required String areaGeohash,
|
required String areaGeohash,
|
||||||
String? category,
|
String? category,
|
||||||
|
bool isOrganic = false,
|
||||||
num? priceAmount,
|
num? priceAmount,
|
||||||
String? priceCurrency,
|
String? priceCurrency,
|
||||||
String? exchangeTerms,
|
String? exchangeTerms,
|
||||||
|
|
@ -43,6 +44,7 @@ class OfferMapper {
|
||||||
type: type,
|
type: type,
|
||||||
approxGeohash: areaGeohash,
|
approxGeohash: areaGeohash,
|
||||||
category: category,
|
category: category,
|
||||||
|
isOrganic: isOrganic,
|
||||||
priceAmount: type == OfferType.sale ? priceAmount : null,
|
priceAmount: type == OfferType.sale ? priceAmount : null,
|
||||||
priceCurrency: type == OfferType.sale ? priceCurrency : null,
|
priceCurrency: type == OfferType.sale ? priceCurrency : null,
|
||||||
exchangeTerms: type == OfferType.exchange ? exchangeTerms : null,
|
exchangeTerms: type == OfferType.exchange ? exchangeTerms : null,
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,9 @@ class OffersState extends Equatable {
|
||||||
this.offers = const [],
|
this.offers = const [],
|
||||||
this.areaGeohash = '',
|
this.areaGeohash = '',
|
||||||
this.query = '',
|
this.query = '',
|
||||||
|
this.typeFilter = const {},
|
||||||
|
this.categoryFilter = const {},
|
||||||
|
this.organicOnly = false,
|
||||||
this.searching = false,
|
this.searching = false,
|
||||||
this.publishing = false,
|
this.publishing = false,
|
||||||
this.hasSearched = 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).
|
/// Free-text filter over [offers] typed in the search box (empty = show all).
|
||||||
final String query;
|
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 searching;
|
||||||
final bool publishing;
|
final bool publishing;
|
||||||
|
|
||||||
|
|
@ -42,18 +54,47 @@ class OffersState extends Equatable {
|
||||||
/// Last error, in human terms for the UI (null when fine).
|
/// Last error, in human terms for the UI (null when fine).
|
||||||
final String? error;
|
final String? error;
|
||||||
|
|
||||||
/// [offers] narrowed by [query], matched against the offer summary. Kept
|
/// [offers] narrowed by the text [query] and the chip filters (all ANDed).
|
||||||
/// separate from [offers] so a search never drops the underlying discoveries.
|
/// Kept separate from [offers] so filtering never drops the discoveries.
|
||||||
List<Offer> get visibleOffers {
|
List<Offer> get visibleOffers {
|
||||||
final q = query.trim().toLowerCase();
|
final q = query.trim().toLowerCase();
|
||||||
if (q.isEmpty) return offers;
|
return offers.where((o) {
|
||||||
return offers.where((o) => o.summary.toLowerCase().contains(q)).toList();
|
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({
|
OffersState copyWith({
|
||||||
List<Offer>? offers,
|
List<Offer>? offers,
|
||||||
String? areaGeohash,
|
String? areaGeohash,
|
||||||
String? query,
|
String? query,
|
||||||
|
Set<OfferType>? typeFilter,
|
||||||
|
Set<String>? categoryFilter,
|
||||||
|
bool? organicOnly,
|
||||||
bool? searching,
|
bool? searching,
|
||||||
bool? publishing,
|
bool? publishing,
|
||||||
bool? hasSearched,
|
bool? hasSearched,
|
||||||
|
|
@ -63,6 +104,9 @@ class OffersState extends Equatable {
|
||||||
offers: offers ?? this.offers,
|
offers: offers ?? this.offers,
|
||||||
areaGeohash: areaGeohash ?? this.areaGeohash,
|
areaGeohash: areaGeohash ?? this.areaGeohash,
|
||||||
query: query ?? this.query,
|
query: query ?? this.query,
|
||||||
|
typeFilter: typeFilter ?? this.typeFilter,
|
||||||
|
categoryFilter: categoryFilter ?? this.categoryFilter,
|
||||||
|
organicOnly: organicOnly ?? this.organicOnly,
|
||||||
searching: searching ?? this.searching,
|
searching: searching ?? this.searching,
|
||||||
publishing: publishing ?? this.publishing,
|
publishing: publishing ?? this.publishing,
|
||||||
hasSearched: hasSearched ?? this.hasSearched,
|
hasSearched: hasSearched ?? this.hasSearched,
|
||||||
|
|
@ -71,8 +115,18 @@ class OffersState extends Equatable {
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object?> get props =>
|
List<Object?> get props => [
|
||||||
[offers, areaGeohash, query, searching, publishing, hasSearched, error];
|
offers,
|
||||||
|
areaGeohash,
|
||||||
|
query,
|
||||||
|
typeFilter,
|
||||||
|
categoryFilter,
|
||||||
|
organicOnly,
|
||||||
|
searching,
|
||||||
|
publishing,
|
||||||
|
hasSearched,
|
||||||
|
error,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drives offer discovery and publishing over an [OfferTransport]. Depends on
|
/// Drives offer discovery and publishing over an [OfferTransport]. Depends on
|
||||||
|
|
@ -106,13 +160,17 @@ class OffersCubit extends Cubit<OffersState> {
|
||||||
_searchTimeout?.cancel();
|
_searchTimeout?.cancel();
|
||||||
emit(OffersState(
|
emit(OffersState(
|
||||||
areaGeohash: geohashPrefix,
|
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,
|
searching: true,
|
||||||
hasSearched: true,
|
hasSearched: true,
|
||||||
));
|
));
|
||||||
_sub = transport.discover(DiscoveryQuery(geohashPrefix: geohashPrefix)).listen(
|
_sub = transport.discover(DiscoveryQuery(geohashPrefix: geohashPrefix)).listen(
|
||||||
(offer) =>
|
(offer) =>
|
||||||
emit(state.copyWith(offers: [...state.offers, offer], searching: false)),
|
emit(state.copyWith(offers: _merge(state.offers, offer), searching: false)),
|
||||||
onError: (Object e) =>
|
onError: (Object e) =>
|
||||||
emit(state.copyWith(searching: false, error: () => '$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
|
/// Narrows the visible offers to those whose summary matches [query]. Purely
|
||||||
/// local over the already-discovered list; does not re-hit the transport.
|
/// local over the already-discovered list; does not re-hit the transport.
|
||||||
void search(String query) => emit(state.copyWith(query: query));
|
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
|
/// Publishes [offer]; returns the transport's verdict. No-op result when
|
||||||
/// offline.
|
/// offline.
|
||||||
Future<PublishResult> publish(Offer offer) async {
|
Future<PublishResult> publish(Offer offer) async {
|
||||||
|
|
@ -172,6 +268,7 @@ class OffersCubit extends Cubit<OffersState> {
|
||||||
sharing: lot.offerStatus,
|
sharing: lot.offerStatus,
|
||||||
areaGeohash: areaGeohash,
|
areaGeohash: areaGeohash,
|
||||||
category: lot.category,
|
category: lot.category,
|
||||||
|
isOrganic: lot.isOrganic,
|
||||||
);
|
);
|
||||||
final result = await transport.publish(offer);
|
final result = await transport.publish(offer);
|
||||||
if (result.accepted) accepted++;
|
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_service.dart';
|
||||||
import '../services/social_settings.dart';
|
import '../services/social_settings.dart';
|
||||||
import '../state/offers_cubit.dart';
|
import '../state/offers_cubit.dart';
|
||||||
|
import 'market_widgets.dart';
|
||||||
import 'theme.dart';
|
import 'theme.dart';
|
||||||
|
|
||||||
/// The market (redesign screens 04/05): discover seeds shared near you. Opens
|
/// The market (redesign screens 04/05): discover seeds shared near you. Opens
|
||||||
|
|
@ -300,6 +301,7 @@ class MarketBody extends StatelessWidget {
|
||||||
onChanged: cubit.search,
|
onChanged: cubit.search,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
_MarketFilterBar(state: state),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: RefreshIndicator(
|
child: RefreshIndicator(
|
||||||
onRefresh: refresh,
|
onRefresh: refresh,
|
||||||
|
|
@ -329,10 +331,8 @@ class MarketBody extends StatelessWidget {
|
||||||
return _OfferCard(
|
return _OfferCard(
|
||||||
offer: o,
|
offer: o,
|
||||||
mine: mine,
|
mine: mine,
|
||||||
onContact: mine
|
onTap: () =>
|
||||||
? null
|
context.push('/market/offer', extra: o),
|
||||||
: () =>
|
|
||||||
context.push('/chat/${o.authorPubkeyHex}'),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
@ -348,119 +348,177 @@ class MarketBody extends StatelessWidget {
|
||||||
/// One discovered offer. Human words for the reciprocity mode; coarse "near you"
|
/// One discovered offer. Human words for the reciprocity mode; coarse "near you"
|
||||||
/// instead of any precise location; price only when it's a sale.
|
/// instead of any precise location; price only when it's a sale.
|
||||||
class _OfferCard extends StatelessWidget {
|
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;
|
final Offer offer;
|
||||||
|
|
||||||
/// Whether this is the current user's own listing (shown with a "You" badge).
|
/// Whether this is the current user's own listing (shown with a "You" badge).
|
||||||
final bool mine;
|
final bool mine;
|
||||||
|
|
||||||
/// Opens a private chat with the author; null for our own listings.
|
/// Opens the offer's detail ("product") page.
|
||||||
final VoidCallback? onContact;
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final t = context.t;
|
final t = context.t;
|
||||||
return Container(
|
final radius = BorderRadius.circular(14);
|
||||||
decoration: BoxDecoration(
|
return Material(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: radius,
|
||||||
border: Border.all(color: seedOutline),
|
child: InkWell(
|
||||||
),
|
key: Key('market.offer.${offer.authorPubkeyHex}.${offer.id}'),
|
||||||
padding: const EdgeInsets.all(16),
|
onTap: onTap,
|
||||||
child: Column(
|
borderRadius: radius,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
child: Container(
|
||||||
children: [
|
decoration: BoxDecoration(
|
||||||
Row(
|
borderRadius: radius,
|
||||||
|
border: Border.all(color: seedOutline),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Row(
|
||||||
child: Text(
|
children: [
|
||||||
offer.summary,
|
Expanded(
|
||||||
style: const TextStyle(
|
child: Text(
|
||||||
fontSize: 17,
|
offer.summary,
|
||||||
fontWeight: FontWeight.w500,
|
style: const TextStyle(
|
||||||
color: seedOnSurface,
|
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,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
if (mine) ...[
|
||||||
const SizedBox(width: 6),
|
Container(
|
||||||
],
|
padding: const EdgeInsets.symmetric(
|
||||||
_TypeChip(label: _offerTypeLabel(t, offer.type)),
|
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) {
|
/// Optional filters over the discovered offers, mirroring the inventory filter
|
||||||
OfferType.gift => t.share.gift,
|
/// bar: reciprocity mode (give/swap/sell/wanted), category, and the eco flag.
|
||||||
OfferType.exchange => t.share.exchange,
|
/// Each chip group only appears when some discovered offer can match it.
|
||||||
OfferType.sale => t.share.sell,
|
class _MarketFilterBar extends StatelessWidget {
|
||||||
OfferType.wanted => t.market.wanted,
|
const _MarketFilterBar({required this.state});
|
||||||
OfferType.lend => t.share.exchange,
|
|
||||||
};
|
|
||||||
|
|
||||||
class _TypeChip extends StatelessWidget {
|
final OffersState state;
|
||||||
const _TypeChip({required this.label});
|
|
||||||
|
|
||||||
final String label;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Container(
|
final t = context.t;
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
final cubit = context.read<OffersCubit>();
|
||||||
decoration: BoxDecoration(
|
final categories = state.categories;
|
||||||
color: seedPrimaryContainer,
|
// Only offer type chips for modes some discovered offer actually holds.
|
||||||
borderRadius: BorderRadius.circular(20),
|
final types = <OfferType>[
|
||||||
),
|
for (final type in const [
|
||||||
child: Text(
|
OfferType.gift,
|
||||||
label,
|
OfferType.exchange,
|
||||||
style: const TextStyle(
|
OfferType.sale,
|
||||||
color: seedOnPrimaryContainer,
|
OfferType.wanted,
|
||||||
fontSize: 12,
|
])
|
||||||
fontWeight: FontWeight.w600,
|
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 {}
|
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() {
|
void main() {
|
||||||
group('OfferMapper', () {
|
group('OfferMapper', () {
|
||||||
test('maps local sharing intent to the network offer type', () {
|
test('maps local sharing intent to the network offer type', () {
|
||||||
|
|
@ -79,6 +111,26 @@ void main() {
|
||||||
expect(sale.priceAmount, 3);
|
expect(sale.priceAmount, 3);
|
||||||
expect(sale.priceCurrency, 'G1');
|
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', () {
|
group('OffersCubit', () {
|
||||||
|
|
@ -347,5 +399,117 @@ void main() {
|
||||||
expect(result.accepted, isFalse);
|
expect(result.accepted, isFalse);
|
||||||
await cubit.close();
|
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
|
tags.add(['g', coarse.substring(0, i)]); // NIP-52 prefix ladder, coarse only
|
||||||
}
|
}
|
||||||
if (offer.category != null) tags.add(['t', offer.category!]);
|
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.radiusKm != null) tags.add(['radius_km', '${offer.radiusKm}']);
|
||||||
if (offer.type == OfferType.sale && offer.priceAmount != null) {
|
if (offer.type == OfferType.sale && offer.priceAmount != null) {
|
||||||
tags.add(['price', '${offer.priceAmount}', offer.priceCurrency ?? 'EUR']);
|
tags.add(['price', '${offer.priceAmount}', offer.priceCurrency ?? 'EUR']);
|
||||||
|
|
@ -62,6 +63,7 @@ class Nip99Codec {
|
||||||
priceCurrency: price != null && price.length > 2 ? price[2] : null,
|
priceCurrency: price != null && price.length > 2 ? price[2] : null,
|
||||||
exchangeTerms: _tagValue(event, 'exchange_terms'),
|
exchangeTerms: _tagValue(event, 'exchange_terms'),
|
||||||
imageUrl: _tagValue(event, 'image'),
|
imageUrl: _tagValue(event, 'image'),
|
||||||
|
isOrganic: _tagValue(event, 'organic') == '1',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ class Offer {
|
||||||
this.radiusKm,
|
this.radiusKm,
|
||||||
this.imageUrl,
|
this.imageUrl,
|
||||||
this.expiresAt,
|
this.expiresAt,
|
||||||
|
this.isOrganic = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Stable per-offer identifier (NIP-99 `d` tag → addressable event).
|
/// Stable per-offer identifier (NIP-99 `d` tag → addressable event).
|
||||||
|
|
@ -57,6 +58,10 @@ class Offer {
|
||||||
|
|
||||||
final String? imageUrl;
|
final String? imageUrl;
|
||||||
final DateTime? expiresAt;
|
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.
|
/// Result of publishing to a transport.
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,43 @@ void main() {
|
||||||
await bobT.close();
|
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)',
|
test('exact geohash never leaves the device (coarsened on the wire)',
|
||||||
() async {
|
() async {
|
||||||
final alice = await idFor(1);
|
final alice = await idFor(1);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue