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/offer_outbox.dart'; import '../services/social_service.dart'; import '../services/social_settings.dart'; import '../state/offers_cubit.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, this.location, this.outbox, super.key, }); final SocialService social; final SocialSettings settings; /// 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; @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.social, widget.settings); final area = await widget.settings.areaGeohash(); if (!mounted) { await cubit.close(); return; } final previous = _cubit; setState(() { _cubit = cubit; _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) await cubit.discover(area); } } 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 messenger.showSnackBar(SnackBar(content: Text(t.market.sharedCount(n: count)))); await cubit.discover(area); // refresh — now including the just-shared lots } @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: [ if (cubit != null && (cubit.isOnline || widget.outbox != null)) IconButton( key: const Key('market.shareMine'), icon: const Icon(Icons.ios_share_outlined), tooltip: t.market.shareMine, onPressed: _shareMine, ), IconButton( key: const Key('market.config'), icon: const Icon(Icons.tune), tooltip: t.market.configTitle, onPressed: _openConfig, ), ], ), body: _loading || cubit == null ? const Center(child: CircularProgressIndicator()) : BlocProvider.value( value: cubit, child: MarketBody( onConfigure: _openConfig, selfPubkey: widget.social.publicKeyHex, ), ), ); } } class MarketBody extends StatelessWidget { const MarketBody({ required this.onConfigure, this.selfPubkey, super.key, }); final VoidCallback onConfigure; /// 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) { return _EmptyState( icon: Icons.wifi_tethering_off_outlined, title: t.market.notSetUp, body: t.market.notSetUpBody, 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)), ], ), ); } if (state.offers.isEmpty) { return _EmptyState( icon: Icons.grass_outlined, title: t.market.empty, body: '', ); } return ListView.separated( padding: const EdgeInsets.all(16), itemCount: state.offers.length, separatorBuilder: (_, _) => const SizedBox(height: 12), itemBuilder: (context, i) { final o = state.offers[i]; return _OfferCard( offer: o, onContact: o.authorPubkeyHex == selfPubkey ? null : () => context.go('/chat/${o.authorPubkeyHex}'), ); }, ); }, ); } } /// 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.onContact}); final Offer offer; /// Opens a private chat with the author; null for our own listings. final VoidCallback? onContact; @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( children: [ Expanded( child: Text( offer.summary, style: const TextStyle( fontSize: 17, fontWeight: FontWeight.w500, color: seedOnSurface, ), ), ), _TypeChip(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)), 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, }; class _TypeChip extends StatelessWidget { const _TypeChip({required this.label}); 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, ), ), ); } } 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(); final Set _selectedRelays = {}; bool _loading = true; bool _locationBusy = false; String? _locationError; @override void initState() { super.initState(); _load(); } Future _load() async { _area.text = await widget.settings.areaGeohash(); final current = await widget.settings.relayUrls(); final presetUrls = SocialSettings.presets.map((p) => p.url).toSet(); _selectedRelays ..clear() ..addAll(current.where(presetUrls.contains)); // Any relays that aren't presets go in the "add another" field. _servers.text = current.where((u) => !presetUrls.contains(u)).join('\n'); if (mounted) setState(() => _loading = false); } Future _save() async { await widget.settings.setAreaGeohash(_area.text); await widget.settings.setRelayUrls([ ..._selectedRelays, ..._servers.text.split('\n'), ]); 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; 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: 16), TextField( key: const Key('market.area'), controller: _area, decoration: InputDecoration( labelText: t.market.areaLabel, helperText: t.market.areaHelp, helperMaxLines: 3, ), ), if (widget.location != null) Align( alignment: AlignmentDirectional.centerStart, child: TextButton.icon( key: const Key('market.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), onPressed: _locationBusy ? null : _useLocation, ), ), if (_locationError != null) Padding( padding: const EdgeInsets.only(top: 4), child: Text( _locationError!, style: const TextStyle(color: Color(0xFFB3261E), fontSize: 12), ), ), const SizedBox(height: 16), // Pick from a short list of shared servers — pre-selected so it // works out of the box; no jargon-y blank field to puzzle over. Text( t.market.serversLabel, style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w600, color: seedOnSurface, ), ), Text( t.market.serversHelp, style: const TextStyle(color: seedMuted, fontSize: 12, height: 1.3), ), for (final preset in SocialSettings.presets) CheckboxListTile( key: Key('market.relay.${preset.name}'), contentPadding: EdgeInsets.zero, dense: true, controlAffinity: ListTileControlAffinity.leading, value: _selectedRelays.contains(preset.url), title: Text(preset.name), subtitle: Text(preset.url, style: const TextStyle(fontSize: 11, color: seedMuted)), onChanged: (checked) => setState(() { if (checked ?? false) { _selectedRelays.add(preset.url); } else { _selectedRelays.remove(preset.url); } }), ), // A rarely-needed escape hatch for a community's own server. Theme( data: Theme.of(context) .copyWith(dividerColor: Colors.transparent), child: ExpansionTile( key: const Key('market.serversAdvanced'), tilePadding: EdgeInsets.zero, childrenPadding: const EdgeInsets.only(bottom: 8), title: Text( t.market.serversAdvanced, style: const TextStyle(fontSize: 13, color: seedMuted), ), children: [ TextField( key: const Key('market.servers'), controller: _servers, minLines: 1, maxLines: 3, decoration: const InputDecoration( hintText: 'wss://…', ), ), ], ), ), const SizedBox(height: 20), FilledButton( key: const Key('market.save'), onPressed: _save, child: Text(t.market.save), ), ], ), ), ); } }