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:
vjrj 2026-07-10 19:11:59 +02:00
commit 69f63d2b62
21 changed files with 972 additions and 107 deletions

View file

@ -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';
@ -26,6 +27,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';
@ -130,6 +132,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',

View file

@ -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,
),
];
}

View file

@ -379,7 +379,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",

View file

@ -382,7 +382,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",

View file

@ -381,7 +381,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",

View file

@ -378,7 +378,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",

View file

@ -4,9 +4,9 @@
/// To regenerate, run: `dart run slang`
///
/// 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
// ignore_for_file: type=lint, unused_import

View file

@ -662,6 +662,10 @@ class _Translations$market$ast extends Translations$market$en {
@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
@ -1408,6 +1412,10 @@ extension on TranslationsAst {
'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',

View file

@ -1215,6 +1215,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
@ -2134,6 +2146,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',

View file

@ -664,6 +664,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
@ -1412,6 +1416,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',

View file

@ -661,6 +661,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
@ -1406,6 +1410,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',

View file

@ -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,

View file

@ -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++;

View 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)),
],
],
);
}
}

View file

@ -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),
),
],
),
);
}

View 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,
),
),
);
}
}

View file

@ -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();
});
});
}

View 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);
});
}

View file

@ -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',
);
}

View file

@ -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.

View file

@ -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);