import 'package:commons_core/commons_core.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../i18n/strings.g.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, super.key}); final SocialService social; final SocialSettings settings; @override State createState() => _MarketScreenState(); } class _MarketScreenState extends State { OffersCubit? _cubit; bool _loading = true; @override void initState() { super.initState(); _init(); } Future _init() async { 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) await cubit.discover(area); } Future _openConfig() async { final changed = await showModalBottomSheet( context: context, isScrollControlled: true, builder: (_) => _ConfigSheet(settings: widget.settings), ); if (changed == true) await _init(); } @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, ), ], ), body: _loading || cubit == null ? const Center(child: CircularProgressIndicator()) : BlocProvider.value( value: cubit, child: MarketBody(onConfigure: _openConfig), ), ); } } class MarketBody extends StatelessWidget { const MarketBody({required this.onConfigure, super.key}); final VoidCallback onConfigure; @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) => _OfferCard(offer: state.offers[i]), ); }, ); } } /// 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}); final Offer offer; @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)), if (offer.type == OfferType.sale && offer.priceAmount != null) ...[ const Spacer(), Text( '${offer.priceAmount} ${offer.priceCurrency ?? ''}'.trim(), style: const TextStyle( color: seedOnSurface, fontWeight: FontWeight.w600, ), ), ], ], ), ], ), ); } } 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}); final SocialSettings settings; @override State<_ConfigSheet> createState() => _ConfigSheetState(); } class _ConfigSheetState extends State<_ConfigSheet> { final _area = TextEditingController(); final _servers = TextEditingController(); bool _loading = true; @override void initState() { super.initState(); _load(); } Future _load() async { _area.text = await widget.settings.areaGeohash(); _servers.text = (await widget.settings.relayUrls()).join('\n'); if (mounted) setState(() => _loading = false); } Future _save() async { await widget.settings.setAreaGeohash(_area.text); await widget.settings.setRelayUrls(_servers.text.split('\n')); if (mounted) Navigator.of(context).pop(true); } @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())) : 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: 16), TextField( key: const Key('market.area'), controller: _area, decoration: InputDecoration( labelText: t.market.areaLabel, helperText: t.market.areaHelp, helperMaxLines: 3, ), ), const SizedBox(height: 16), TextField( key: const Key('market.servers'), controller: _servers, minLines: 2, maxLines: 4, decoration: InputDecoration( labelText: t.market.serversLabel, helperText: t.market.serversHelp, helperMaxLines: 3, ), ), const SizedBox(height: 20), FilledButton( key: const Key('market.save'), onPressed: _save, child: Text(t.market.save), ), ], ), ); } }