feat(market): fix publish-duplicates, add offer detail page + filters
The vecino/market view had three gaps: - Publishing inventory duplicated the listing. discover() appended every offer the relay streamed, but relays legitimately resend an addressable NIP-99 event (stored copy + live echo on publish). Merge by (authorPubkeyHex, id) so each offer appears once; two authors reusing a lot id stay distinct. - No product detail. Tapping a card jumped straight to chat. Add a read-only "product page" (MarketOfferDetailScreen) with title, mode, category, eco badge, coarse location, price, a "Shared by" seller section (profile fetched by pubkey) and the message button. Reached via a new /market/offer route; cards are now fully tappable. Shows only what the network already carries — no photos/description added to the wire. - No filters. Add a filter bar (type, category, eco) mirroring inventory, each chip group shown only when a discovered offer can match it. To make the eco filter real, publish the organic flag on the wire: Offer.isOrganic -> NIP-99 'organic' tag -> OfferMapper -> ShareableLot. Tests: commons_core organic round-trip; app-side dedup, two-author separation, filter logic, mapper passthrough; detail-screen widget tests. i18n keys added to en/es/pt/ast and slang regenerated.
This commit is contained in:
parent
e852b569ce
commit
54d7c2d3b5
21 changed files with 982 additions and 107 deletions
307
apps/app_seeds/lib/ui/market_offer_detail_screen.dart
Normal file
307
apps/app_seeds/lib/ui/market_offer_detail_screen.dart
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:commons_core/commons_core.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../i18n/strings.g.dart';
|
||||
import '../services/profile_cache.dart';
|
||||
import '../services/social_service.dart';
|
||||
import '../services/social_settings.dart';
|
||||
import 'market_widgets.dart';
|
||||
import 'theme.dart';
|
||||
|
||||
/// Read-only "product page" for one discovered [Offer] (the Wallapop-style
|
||||
/// detail behind a market card). Shows only what the author elected to publish —
|
||||
/// summary, reciprocity mode, category, coarse area, price, and the eco flag —
|
||||
/// plus who's sharing it and a way to message them. It never fetches inventory
|
||||
/// or an exact address: the privacy seam means the network simply has no more.
|
||||
class MarketOfferDetailScreen extends StatefulWidget {
|
||||
const MarketOfferDetailScreen({
|
||||
required this.offer,
|
||||
required this.social,
|
||||
required this.settings,
|
||||
this.mine = false,
|
||||
this.profileCache,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final Offer offer;
|
||||
final SocialService social;
|
||||
final SocialSettings settings;
|
||||
|
||||
/// Whether this is the current user's own listing (hide the message button).
|
||||
final bool mine;
|
||||
|
||||
/// Optional cache of peers' published display names; null in tests.
|
||||
final ProfileCache? profileCache;
|
||||
|
||||
@override
|
||||
State<MarketOfferDetailScreen> createState() =>
|
||||
_MarketOfferDetailScreenState();
|
||||
}
|
||||
|
||||
class _MarketOfferDetailScreenState extends State<MarketOfferDetailScreen> {
|
||||
SocialSession? _session;
|
||||
String? _sellerName;
|
||||
String? _sellerAbout;
|
||||
String? _sellerG1;
|
||||
bool _profileLoading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadSeller();
|
||||
}
|
||||
|
||||
/// Best-effort fetch of the author's published profile (name/about/Ğ1), the
|
||||
/// same path the chat header uses. Degrades silently when offline.
|
||||
Future<void> _loadSeller() async {
|
||||
final cachedName = await widget.profileCache?.name(widget.offer.authorPubkeyHex);
|
||||
if (mounted && cachedName != null) setState(() => _sellerName = cachedName);
|
||||
|
||||
final relays = await widget.settings.relayUrls();
|
||||
SocialSession? session;
|
||||
if (relays.isNotEmpty) {
|
||||
try {
|
||||
session = await widget.social.openSession(relays);
|
||||
} catch (_) {
|
||||
session = null;
|
||||
}
|
||||
}
|
||||
if (!mounted) {
|
||||
await session?.close();
|
||||
return;
|
||||
}
|
||||
_session = session;
|
||||
if (session == null) {
|
||||
setState(() => _profileLoading = false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final profile = await session.profile.fetch(widget.offer.authorPubkeyHex);
|
||||
if (profile != null && profile.name.isNotEmpty) {
|
||||
unawaited(widget.profileCache
|
||||
?.setName(widget.offer.authorPubkeyHex, profile.name));
|
||||
}
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
if (profile != null && profile.name.isNotEmpty) {
|
||||
_sellerName = profile.name;
|
||||
}
|
||||
_sellerAbout = profile?.about.isNotEmpty == true ? profile!.about : null;
|
||||
_sellerG1 = profile?.g1.isNotEmpty == true ? profile!.g1 : null;
|
||||
_profileLoading = false;
|
||||
});
|
||||
}
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _profileLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_session?.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _copyId() async {
|
||||
final t = context.t;
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
await Clipboard.setData(
|
||||
ClipboardData(text: widget.offer.authorPubkeyHex));
|
||||
messenger.showSnackBar(SnackBar(content: Text(t.market.idCopied)));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = context.t;
|
||||
final o = widget.offer;
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(o.summary)),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
o.summary,
|
||||
style: const TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: seedTitle,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
OfferTypeChip(label: offerTypeLabel(t, o.type)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
if (o.isOrganic)
|
||||
_Badge(
|
||||
icon: Icons.eco,
|
||||
label: t.editVariety.organic,
|
||||
),
|
||||
if (o.category != null && o.category!.isNotEmpty)
|
||||
_Badge(icon: Icons.folder_outlined, label: o.category!),
|
||||
_Badge(icon: Icons.place_outlined, label: t.market.near),
|
||||
],
|
||||
),
|
||||
if (o.type == OfferType.sale && o.priceAmount != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'${o.priceAmount} ${o.priceCurrency ?? ''}'.trim(),
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: seedOnSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (o.type == OfferType.exchange &&
|
||||
o.exchangeTerms != null &&
|
||||
o.exchangeTerms!.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
o.exchangeTerms!,
|
||||
style: const TextStyle(color: seedOnSurface, fontSize: 15),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
const Divider(),
|
||||
const SizedBox(height: 12),
|
||||
_SellerSection(
|
||||
title: t.market.sharedBy,
|
||||
name: _sellerName ?? shortPubkey(o.authorPubkeyHex),
|
||||
about: _sellerAbout,
|
||||
loading: _profileLoading,
|
||||
hasProfile:
|
||||
_sellerName != null || _sellerAbout != null || _sellerG1 != null,
|
||||
noProfileLabel: t.market.noProfile,
|
||||
onCopyId: _copyId,
|
||||
copyIdTooltip: t.market.copyId,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
if (!widget.mine)
|
||||
FilledButton.icon(
|
||||
key: const Key('offerDetail.contact'),
|
||||
onPressed: () => context.push('/chat/${o.authorPubkeyHex}'),
|
||||
icon: const Icon(Icons.chat_bubble_outline),
|
||||
label: Text(t.market.contact),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A small pill with an icon + label (eco, category, "near you").
|
||||
class _Badge extends StatelessWidget {
|
||||
const _Badge({required this.icon, required this.label});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: seedPrimaryContainer.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 15, color: seedGreen),
|
||||
const SizedBox(width: 5),
|
||||
Text(label,
|
||||
style: const TextStyle(color: seedOnSurface, fontSize: 13)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// "Shared by" card: who's offering this, their bio, and a copy-code affordance.
|
||||
class _SellerSection extends StatelessWidget {
|
||||
const _SellerSection({
|
||||
required this.title,
|
||||
required this.name,
|
||||
required this.about,
|
||||
required this.loading,
|
||||
required this.hasProfile,
|
||||
required this.noProfileLabel,
|
||||
required this.onCopyId,
|
||||
required this.copyIdTooltip,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String name;
|
||||
final String? about;
|
||||
final bool loading;
|
||||
final bool hasProfile;
|
||||
final String noProfileLabel;
|
||||
final VoidCallback onCopyId;
|
||||
final String copyIdTooltip;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: seedMuted,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
const CircleAvatar(
|
||||
radius: 20,
|
||||
backgroundColor: seedPrimaryContainer,
|
||||
child: Icon(Icons.person_outline, color: seedGreen),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
name,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: seedOnSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
key: const Key('offerDetail.copyId'),
|
||||
icon: const Icon(Icons.copy, size: 18, color: seedMuted),
|
||||
tooltip: copyIdTooltip,
|
||||
onPressed: onCopyId,
|
||||
),
|
||||
],
|
||||
),
|
||||
if (about != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(about!,
|
||||
style: const TextStyle(color: seedOnSurface, fontSize: 14)),
|
||||
] else if (!loading && !hasProfile) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(noProfileLabel,
|
||||
style: const TextStyle(color: seedMuted, fontSize: 13)),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ import '../services/offer_outbox.dart';
|
|||
import '../services/social_service.dart';
|
||||
import '../services/social_settings.dart';
|
||||
import '../state/offers_cubit.dart';
|
||||
import 'market_widgets.dart';
|
||||
import 'theme.dart';
|
||||
|
||||
/// The market (redesign screens 04/05): discover seeds shared near you. Opens
|
||||
|
|
@ -300,6 +301,7 @@ class MarketBody extends StatelessWidget {
|
|||
onChanged: cubit.search,
|
||||
),
|
||||
),
|
||||
_MarketFilterBar(state: state),
|
||||
Expanded(
|
||||
child: RefreshIndicator(
|
||||
onRefresh: refresh,
|
||||
|
|
@ -329,10 +331,8 @@ class MarketBody extends StatelessWidget {
|
|||
return _OfferCard(
|
||||
offer: o,
|
||||
mine: mine,
|
||||
onContact: mine
|
||||
? null
|
||||
: () =>
|
||||
context.push('/chat/${o.authorPubkeyHex}'),
|
||||
onTap: () =>
|
||||
context.push('/market/offer', extra: o),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
|
@ -348,119 +348,177 @@ class MarketBody extends StatelessWidget {
|
|||
/// One discovered offer. Human words for the reciprocity mode; coarse "near you"
|
||||
/// instead of any precise location; price only when it's a sale.
|
||||
class _OfferCard extends StatelessWidget {
|
||||
const _OfferCard({required this.offer, this.mine = false, this.onContact});
|
||||
const _OfferCard({required this.offer, this.mine = false, this.onTap});
|
||||
|
||||
final Offer offer;
|
||||
|
||||
/// Whether this is the current user's own listing (shown with a "You" badge).
|
||||
final bool mine;
|
||||
|
||||
/// Opens a private chat with the author; null for our own listings.
|
||||
final VoidCallback? onContact;
|
||||
/// Opens the offer's detail ("product") page.
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = context.t;
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: seedOutline),
|
||||
),
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
final radius = BorderRadius.circular(14);
|
||||
return Material(
|
||||
color: Colors.white,
|
||||
borderRadius: radius,
|
||||
child: InkWell(
|
||||
key: Key('market.offer.${offer.authorPubkeyHex}.${offer.id}'),
|
||||
onTap: onTap,
|
||||
borderRadius: radius,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: radius,
|
||||
border: Border.all(color: seedOutline),
|
||||
),
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
offer.summary,
|
||||
style: const TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: seedOnSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (mine) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: seedGreen,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
t.market.mine,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
offer.summary,
|
||||
style: const TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: seedOnSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
],
|
||||
_TypeChip(label: _offerTypeLabel(t, offer.type)),
|
||||
if (mine) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: seedGreen,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
t.market.mine,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
],
|
||||
OfferTypeChip(label: offerTypeLabel(t, offer.type)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.place_outlined, size: 15, color: seedMuted),
|
||||
const SizedBox(width: 4),
|
||||
Text(t.market.near,
|
||||
style: const TextStyle(color: seedMuted, fontSize: 13)),
|
||||
if (offer.isOrganic) ...[
|
||||
const SizedBox(width: 10),
|
||||
const Icon(Icons.eco, size: 15, color: seedGreen),
|
||||
],
|
||||
const Spacer(),
|
||||
if (offer.type == OfferType.sale && offer.priceAmount != null)
|
||||
Text(
|
||||
'${offer.priceAmount} ${offer.priceCurrency ?? ''}'.trim(),
|
||||
style: const TextStyle(
|
||||
color: seedOnSurface,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
const Icon(Icons.chevron_right, size: 18, color: seedMuted),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.place_outlined, size: 15, color: seedMuted),
|
||||
const SizedBox(width: 4),
|
||||
Text(t.market.near, style: const TextStyle(color: seedMuted, fontSize: 13)),
|
||||
const Spacer(),
|
||||
if (offer.type == OfferType.sale && offer.priceAmount != null)
|
||||
Text(
|
||||
'${offer.priceAmount} ${offer.priceCurrency ?? ''}'.trim(),
|
||||
style: const TextStyle(
|
||||
color: seedOnSurface,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
if (onContact != null)
|
||||
TextButton.icon(
|
||||
onPressed: onContact,
|
||||
icon: const Icon(Icons.chat_bubble_outline, size: 16),
|
||||
label: Text(t.market.contact),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _offerTypeLabel(Translations t, OfferType type) => switch (type) {
|
||||
OfferType.gift => t.share.gift,
|
||||
OfferType.exchange => t.share.exchange,
|
||||
OfferType.sale => t.share.sell,
|
||||
OfferType.wanted => t.market.wanted,
|
||||
OfferType.lend => t.share.exchange,
|
||||
};
|
||||
/// Optional filters over the discovered offers, mirroring the inventory filter
|
||||
/// bar: reciprocity mode (give/swap/sell/wanted), category, and the eco flag.
|
||||
/// Each chip group only appears when some discovered offer can match it.
|
||||
class _MarketFilterBar extends StatelessWidget {
|
||||
const _MarketFilterBar({required this.state});
|
||||
|
||||
class _TypeChip extends StatelessWidget {
|
||||
const _TypeChip({required this.label});
|
||||
|
||||
final String label;
|
||||
final OffersState state;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: seedPrimaryContainer,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
color: seedOnPrimaryContainer,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
final t = context.t;
|
||||
final cubit = context.read<OffersCubit>();
|
||||
final categories = state.categories;
|
||||
// Only offer type chips for modes some discovered offer actually holds.
|
||||
final types = <OfferType>[
|
||||
for (final type in const [
|
||||
OfferType.gift,
|
||||
OfferType.exchange,
|
||||
OfferType.sale,
|
||||
OfferType.wanted,
|
||||
])
|
||||
if (state.offers.any((o) => o.type == type)) type,
|
||||
];
|
||||
final hasOrganic = state.hasOrganic;
|
||||
if (categories.isEmpty && types.isEmpty && !hasOrganic) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final chips = <Widget>[
|
||||
if (hasOrganic)
|
||||
FilterChip(
|
||||
key: const Key('market.filter.organic'),
|
||||
avatar: Icon(
|
||||
Icons.eco,
|
||||
size: 18,
|
||||
color: state.organicOnly ? null : seedGreen,
|
||||
),
|
||||
label: Text(t.editVariety.organic),
|
||||
selected: state.organicOnly,
|
||||
onSelected: (_) => cubit.toggleOrganicOnly(),
|
||||
),
|
||||
for (final type in types)
|
||||
FilterChip(
|
||||
key: Key('market.filter.type.${type.name}'),
|
||||
label: Text(offerTypeLabel(t, type)),
|
||||
selected: state.typeFilter.contains(type),
|
||||
onSelected: (_) => cubit.toggleType(type),
|
||||
),
|
||||
for (final category in categories)
|
||||
FilterChip(
|
||||
key: Key('market.filter.category.$category'),
|
||||
label: Text(category),
|
||||
selected: state.categoryFilter.contains(category),
|
||||
onSelected: (_) => cubit.toggleCategory(category),
|
||||
),
|
||||
];
|
||||
|
||||
return SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
for (final chip in chips)
|
||||
Padding(
|
||||
padding: const EdgeInsetsDirectional.only(end: 8),
|
||||
child: chip,
|
||||
),
|
||||
if (state.hasActiveFilter)
|
||||
TextButton.icon(
|
||||
key: const Key('market.filter.clear'),
|
||||
onPressed: cubit.clearFilters,
|
||||
icon: const Icon(Icons.clear, size: 18),
|
||||
label: Text(t.inventory.clearFilters),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
41
apps/app_seeds/lib/ui/market_widgets.dart
Normal file
41
apps/app_seeds/lib/ui/market_widgets.dart
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import 'package:commons_core/commons_core.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../i18n/strings.g.dart';
|
||||
import 'theme.dart';
|
||||
|
||||
/// Human words for an offer's reciprocity mode. Shared by the market list and
|
||||
/// the offer detail page so both read the same way.
|
||||
String offerTypeLabel(Translations t, OfferType type) => switch (type) {
|
||||
OfferType.gift => t.share.gift,
|
||||
OfferType.exchange => t.share.exchange,
|
||||
OfferType.sale => t.share.sell,
|
||||
OfferType.wanted => t.market.wanted,
|
||||
OfferType.lend => t.share.exchange,
|
||||
};
|
||||
|
||||
/// The small pill showing an offer's reciprocity mode (give/swap/sell/wanted).
|
||||
class OfferTypeChip extends StatelessWidget {
|
||||
const OfferTypeChip({required this.label, super.key});
|
||||
|
||||
final String label;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: seedPrimaryContainer,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
color: seedOnPrimaryContainer,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue