import 'package:commons_core/commons_core.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import '../data/variety_repository.dart'; import '../i18n/strings.g.dart'; import '../services/coarse_location.dart'; import '../services/discovery_area.dart'; import '../services/offer_outbox.dart'; import '../services/social_connection.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 /// an offer transport lazily; local-first, so it degrades to a "set up sharing" /// prompt when no relay/area is configured or the network is unreachable. class MarketScreen extends StatefulWidget { const MarketScreen({ required this.social, required this.settings, required this.connection, this.location, this.outbox, super.key, }); final SocialService social; final SocialSettings settings; /// The shared relay connection (one per identity), reused for discovery. final SocialConnection connection; /// Optional device-location source for the "use my location" shortcut; when /// null (tests, or a platform without it) the shortcut is hidden. final CoarseLocationProvider? location; /// Optional offline outbox: parks shares attempted with no network and flushes /// them on reconnect. Null in tests → sharing offline is simply a no-op. final OfferOutbox? outbox; @override State createState() => _MarketScreenState(); } class _MarketScreenState extends State { OffersCubit? _cubit; bool _loading = true; bool _hasArea = false; @override void initState() { super.initState(); _init(); } Future _init() async { // Only needed to flush the outbox; read here (while mounted) to avoid using // context across the awaits below. Null when there's no outbox (e.g. tests). final repo = widget.outbox != null ? context.read() : null; setState(() => _loading = true); final cubit = await createOffersCubit(widget.connection, repository: repo); final area = await widget.settings.areaGeohash(); if (!mounted) { await cubit.close(); return; } final previous = _cubit; setState(() { _cubit = cubit; _hasArea = area.isNotEmpty; _loading = false; }); await previous?.close(); if (area.isNotEmpty && cubit.isOnline) { // Flush anything parked while offline, then show what's out there. final outbox = widget.outbox; if (outbox != null && repo != null) { await flushOutbox( outbox: outbox, cubit: cubit, shareableLots: await repo.shareableLots(), authorPubkeyHex: widget.social.publicKeyHex, areaGeohash: area, ); } if (mounted) { final precision = await widget.settings.searchPrecision(); if (mounted) await cubit.discover(searchPrefix(area, precision)); } } } Future _openConfig() async { final changed = await showModalBottomSheet( context: context, isScrollControlled: true, builder: (_) => _ConfigSheet(settings: widget.settings, location: widget.location), ); if (changed == true) await _init(); } /// Publishes the user's shared lots as offers to the network, tagged with the /// coarse area. Prompts for setup when the area isn't set. Future _shareMine() async { final cubit = _cubit; if (cubit == null) return; final repo = context.read(); final messenger = ScaffoldMessenger.of(context); final t = context.t; final area = await widget.settings.areaGeohash(); if (!mounted) return; if (area.isEmpty) { await _openConfig(); return; } final lots = await repo.shareableLots(); if (!mounted) return; if (lots.isEmpty) { messenger.showSnackBar(SnackBar(content: Text(t.market.nothingToShare))); return; } final ids = lots.map((l) => l.lotId).toList(); // Offline: park it and tell the person we'll share it later. if (!cubit.isOnline) { await widget.outbox?.enqueue(ids); if (!mounted) return; messenger.showSnackBar(SnackBar(content: Text(t.market.queued))); return; } final count = await cubit.publishLots( lots, authorPubkeyHex: widget.social.publicKeyHex, areaGeohash: area, ); if (!mounted) return; await widget.outbox?.remove(ids); // published now, so unpark any duplicates // count == 0 with lots to share means every relay refused — say so plainly // rather than "shared 0", which reads like success. messenger.showSnackBar(SnackBar( content: Text(count == 0 ? t.market.shareFailed : t.market.sharedCount(n: count)), )); final precision = await widget.settings.searchPrecision(); if (!mounted) return; await cubit.discover(searchPrefix(area, precision)); // refresh incl. just-shared } @override void dispose() { _cubit?.close(); super.dispose(); } @override Widget build(BuildContext context) { final t = context.t; final cubit = _cubit; return Scaffold( appBar: AppBar( title: Text(t.market.title), actions: [ IconButton( key: const Key('market.config'), icon: const Icon(Icons.tune), tooltip: t.market.configTitle, onPressed: _openConfig, ), ], ), floatingActionButton: cubit != null && (cubit.isOnline || widget.outbox != null) ? FloatingActionButton.extended( key: const Key('market.shareMine'), onPressed: _shareMine, icon: const Icon(Icons.campaign_outlined), label: Text(t.market.shareMine), ) : null, body: _loading || cubit == null ? const Center(child: CircularProgressIndicator()) : BlocProvider.value( value: cubit, child: MarketBody( onConfigure: _openConfig, onRetry: _init, hasArea: _hasArea, selfPubkey: widget.social.publicKeyHex, ), ), ); } } class MarketBody extends StatelessWidget { const MarketBody({ required this.onConfigure, this.onRetry, this.hasArea = true, this.selfPubkey, super.key, }); final VoidCallback onConfigure; /// Re-opens the connection (for the "can't reach servers" retry). final VoidCallback? onRetry; /// Whether the user has set their coarse area yet. final bool hasArea; /// This user's own pubkey, so we don't offer to message our own listings. final String? selfPubkey; @override Widget build(BuildContext context) { final t = context.t; return BlocBuilder( builder: (context, state) { if (!context.read().isOnline) { // Default community servers exist, so "offline" means unreachable — // offer a retry rather than "set up sharing". return _EmptyState( icon: Icons.wifi_tethering_off_outlined, title: t.market.cantReach, body: t.market.cantReachBody, actionLabel: onRetry != null ? t.market.retry : null, onAction: onRetry, ); } if (!hasArea) { return _EmptyState( icon: Icons.place_outlined, title: t.market.setArea, body: t.market.setAreaBody, actionLabel: t.market.setUp, onAction: onConfigure, ); } if (state.searching && state.offers.isEmpty) { return Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ const CircularProgressIndicator(), const SizedBox(height: 16), Text(t.market.searching, style: const TextStyle(color: seedMuted)), ], ), ); } final cubit = context.read(); // Pull-to-refresh re-runs discovery for the current area (keeping the // text filter), so the list is never stuck on a stale scan. Future refresh() => cubit.discover(state.areaGeohash); // Nothing discovered at all: keep the friendly empty state, but make it // pull-to-refreshable so a swipe re-scans. if (state.offers.isEmpty) { return RefreshIndicator( onRefresh: refresh, child: ListView( // AlwaysScrollable so the pull gesture works with a single child. physics: const AlwaysScrollableScrollPhysics(), children: [ SizedBox( height: MediaQuery.of(context).size.height * 0.6, child: _EmptyState( icon: Icons.grass_outlined, title: t.market.empty, body: '', ), ), ], ), ); } final visible = state.visibleOffers; return Column( children: [ Padding( padding: const EdgeInsets.fromLTRB(12, 12, 12, 4), child: TextField( key: const Key('market.search'), decoration: InputDecoration( hintText: t.market.searchHint, prefixIcon: const Icon(Icons.search, color: seedMuted), hintStyle: const TextStyle(color: seedMuted), filled: true, fillColor: Colors.white, isDense: true, contentPadding: const EdgeInsets.symmetric(vertical: 14), border: OutlineInputBorder( borderRadius: BorderRadius.circular(28), borderSide: BorderSide.none, ), ), onChanged: cubit.search, ), ), _MarketFilterBar(state: state), Expanded( child: RefreshIndicator( onRefresh: refresh, child: visible.isEmpty // Discovered offers exist, but the search filtered them out. ? ListView( physics: const AlwaysScrollableScrollPhysics(), children: [ SizedBox( height: MediaQuery.of(context).size.height * 0.5, child: _EmptyState( icon: Icons.search_off, title: t.market.noMatches, body: '', ), ), ], ) : ListView.separated( physics: const AlwaysScrollableScrollPhysics(), padding: const EdgeInsets.all(16), itemCount: visible.length, separatorBuilder: (_, _) => const SizedBox(height: 12), itemBuilder: (context, i) { final o = visible[i]; final mine = o.authorPubkeyHex == selfPubkey; return _OfferCard( offer: o, mine: mine, onTap: () => context.push('/market/offer', extra: o), ); }, ), ), ), ], ); }, ); } } /// 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.onTap}); final Offer offer; /// Whether this is the current user's own listing (shown with a "You" badge). final bool mine; /// Opens the offer's detail ("product") page. final VoidCallback? onTap; @override Widget build(BuildContext context) { final t = context.t; 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: [ Row( children: [ if (offer.imageUrl != null) ...[ OfferThumbnail( url: offer.imageUrl!, semanticLabel: t.market.photo, ), const SizedBox(width: 12), ], 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, ), ), ), 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), ], ), ], ), ), ), ); } } /// 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}); final OffersState state; @override Widget build(BuildContext context) { final t = context.t; final cubit = context.read(); final categories = state.categories; // Only offer type chips for modes some discovered offer actually holds. final types = [ 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 = [ 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), ), ], ), ); } } class _EmptyState extends StatelessWidget { const _EmptyState({ required this.icon, required this.title, required this.body, this.actionLabel, this.onAction, }); final IconData icon; final String title; final String body; final String? actionLabel; final VoidCallback? onAction; @override Widget build(BuildContext context) { return Center( child: Padding( padding: const EdgeInsets.all(32), child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(icon, size: 56, color: seedMuted), const SizedBox(height: 16), Text( title, textAlign: TextAlign.center, style: const TextStyle( fontSize: 18, fontWeight: FontWeight.w500, color: seedOnSurface, ), ), if (body.isNotEmpty) ...[ const SizedBox(height: 8), Text( body, textAlign: TextAlign.center, style: const TextStyle(color: seedMuted, fontSize: 14), ), ], if (actionLabel != null && onAction != null) ...[ const SizedBox(height: 20), FilledButton(onPressed: onAction, child: Text(actionLabel!)), ], ], ), ), ); } } /// Coarse-area + community-server setup. Kept behind progressive disclosure — a /// power-user surface — with human-worded labels. class _ConfigSheet extends StatefulWidget { const _ConfigSheet({required this.settings, this.location}); final SocialSettings settings; final CoarseLocationProvider? location; @override State<_ConfigSheet> createState() => _ConfigSheetState(); } class _ConfigSheetState extends State<_ConfigSheet> { final _area = TextEditingController(); final _servers = TextEditingController(); bool _loading = true; bool _locationBusy = false; String? _locationError; int _precision = SocialSettings.defaultSearchPrecision; @override void initState() { super.initState(); _load(); } Future _load() async { _area.text = await widget.settings.areaGeohash(); _servers.text = (await widget.settings.relayUrls()).join('\n'); _precision = await widget.settings.searchPrecision(); if (mounted) setState(() => _loading = false); } Future _save() async { await widget.settings.setAreaGeohash(_area.text); await widget.settings.setRelayUrls(_servers.text.split('\n')); await widget.settings.setSearchPrecision(_precision); if (mounted) Navigator.of(context).pop(true); } /// Fills the area from the device's approximate location, reduced to a coarse /// geohash so no precise fix is stored. Future _useLocation() async { final provider = widget.location; if (provider == null || _locationBusy) return; setState(() { _locationBusy = true; _locationError = null; }); final loc = await provider.currentCoarseLatLon(); if (!mounted) return; setState(() { _locationBusy = false; if (loc == null) { _locationError = context.t.market.locationFailed; } else { _area.text = Geohash.encode(loc.lat, loc.lon, precision: 5); } }); } @override void dispose() { _area.dispose(); _servers.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final t = context.t; final hasArea = _area.text.trim().isNotEmpty; return Padding( padding: EdgeInsets.only( left: 20, right: 20, top: 20, bottom: MediaQuery.of(context).viewInsets.bottom + 20, ), child: _loading ? const SizedBox( height: 120, child: Center(child: CircularProgressIndicator())) : SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( t.market.configTitle, style: const TextStyle( fontSize: 18, fontWeight: FontWeight.w600, color: seedTitle, ), ), const SizedBox(height: 8), Text( t.market.setupIntro, style: const TextStyle( color: seedMuted, fontSize: 13, height: 1.4), ), const SizedBox(height: 18), // Setting your area from device location is the human path; // typing a code is the advanced fallback. if (widget.location != null) FilledButton.tonalIcon( key: const Key('market.useLocation'), onPressed: _locationBusy ? null : _useLocation, icon: _locationBusy ? const SizedBox( width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.my_location, size: 18), label: Text(t.market.useLocation), ), if (_locationError != null) Padding( padding: const EdgeInsets.only(top: 6), child: Text( _locationError!, style: const TextStyle( color: Color(0xFFB3261E), fontSize: 12), ), ), const SizedBox(height: 12), Row( children: [ Icon( hasArea ? Icons.check_circle : Icons.pending_outlined, size: 18, color: hasArea ? seedGreen : seedMuted, ), const SizedBox(width: 8), Expanded( child: Text( hasArea ? t.market.areaSet : t.market.areaNotSet, style: TextStyle( color: hasArea ? seedOnSurface : seedMuted, fontSize: 13, ), ), ), ], ), const SizedBox(height: 16), Align( alignment: AlignmentDirectional.centerStart, child: Text( t.market.rangeLabel, style: const TextStyle(fontSize: 13, color: seedMuted), ), ), const SizedBox(height: 8), SegmentedButton( key: const Key('market.range'), segments: [ ButtonSegment(value: 5, label: Text(t.market.rangeNear)), ButtonSegment(value: 4, label: Text(t.market.rangeArea)), ButtonSegment(value: 3, label: Text(t.market.rangeRegion)), ], selected: {_precision}, showSelectedIcon: false, onSelectionChanged: (s) => setState(() => _precision = s.first), ), const SizedBox(height: 8), Theme( data: Theme.of(context) .copyWith(dividerColor: Colors.transparent), child: ExpansionTile( key: const Key('market.advanced'), tilePadding: EdgeInsets.zero, childrenPadding: const EdgeInsets.only(bottom: 8), title: Text( t.market.advanced, style: const TextStyle(fontSize: 13, color: seedMuted), ), children: [ TextField( key: const Key('market.area'), controller: _area, onChanged: (_) => setState(() {}), decoration: InputDecoration( labelText: t.market.areaCodeLabel, helperText: t.market.areaCodeHint, helperMaxLines: 2, ), ), const SizedBox(height: 14), TextField( key: const Key('market.servers'), controller: _servers, minLines: 1, maxLines: 3, decoration: InputDecoration( labelText: t.market.serversLabel, helperText: t.market.serversHelp, helperMaxLines: 2, ), ), ], ), ), const SizedBox(height: 20), FilledButton( key: const Key('market.save'), onPressed: _save, child: Text(t.market.save), ), ], ), ), ); } }