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/saved_searches_store.dart'; import '../services/social_connection.dart'; import '../services/social_service.dart'; import '../services/sharing_switch.dart'; import '../services/social_settings.dart'; import '../services/onboarding_store.dart'; import '../state/offers_cubit.dart'; import 'blocked_people.dart'; import 'edge_fade.dart'; import 'filter_chips.dart'; import 'market_gate.dart'; import 'market_widgets.dart'; import 'saved_searches_screen.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, this.onboarding, this.savedSearches, this.initialSearch, this.sharing, super.key, }); final SocialService social; final SocialSettings settings; /// Optional store of the user's saved searches (with alerts). Null in tests / /// inventory-only → the save affordance and the saved-searches action hide. final SavedSearchesStore? savedSearches; /// When set (arriving from the saved-searches list), the market opens with /// this search's query and facets already applied. final SavedSearch? initialSearch; /// When set, joining the market is gated on a one-time acceptance of the /// community rules (store UGC policies require it before content is /// created). Null in tests → no gate. final OnboardingStore? onboarding; /// The sharing on/off switch. Accepting the rules turns it on (that is the /// moment Tane first goes online); the sharing setup can turn it back off. final SharingSwitch? sharing; /// 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(); _start(); } /// Runs the one-time community-rules gate (when wired) before touching the /// network; declining leaves the market. Future _start() async { final store = widget.onboarding; final sharing = widget.sharing; // The rules step is also the moment Tane first goes online, so it runs // whenever sharing is still off — even for someone who agreed long ago and // later switched sharing back off. if (store != null && (!await store.marketRulesAccepted() || (sharing != null && !sharing.on.value))) { // Wait for the first frame so the sheet has a surface to attach to. await WidgetsBinding.instance.endOfFrame; if (!mounted) return; final ok = await ensureMarketRulesAccepted(context, store, sharing: sharing); if (!ok) { if (mounted) context.pop(); return; } } await _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); cubit.setBlockedAuthors(await widget.settings.blockedPubkeys()); cubit.setHiddenOffers(await widget.settings.hiddenOfferKeys()); final area = await widget.settings.areaGeohash(); if (!mounted) { await cubit.close(); return; } // Arriving from the saved-searches list: pre-apply its query and facets. final initial = widget.initialSearch; if (initial != null) cubit.applySavedSearch(initial); final previous = _cubit; setState(() { _cubit = cubit; _hasArea = area.isNotEmpty; _loading = false; }); await previous?.close(); if (area.isNotEmpty) { // Flush anything parked while offline, then show what's out there. final outbox = widget.outbox; if (outbox != null && repo != null && cubit.isOnline) { await flushOutbox( outbox: outbox, cubit: cubit, shareableLots: await repo.shareableLots(), authorPubkeyHex: widget.social.publicKeyHex, areaGeohash: area, ); } if (mounted) { final precision = await widget.settings.searchPrecision(); // Even when offline: this records the wanted area on the cubit, which // re-runs the discovery by itself once the connection comes up. if (mounted) await cubit.discover(searchPrefix(area, precision)); } } } /// Re-reads the blocklist and hidden (reported) offers — the offer detail /// can change both — so they drop out of the visible list at once. Future _reloadBlocked() async { final cubit = _cubit; if (cubit == null) return; cubit.setBlockedAuthors(await widget.settings.blockedPubkeys()); cubit.setHiddenOffers(await widget.settings.hiddenOfferKeys()); } Future _openConfig() async { final changed = await showModalBottomSheet( context: context, isScrollControlled: true, builder: (_) => _ConfigSheet( settings: widget.settings, location: widget.location, sharing: widget.sharing, ), ); 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 { // Defense in depth: publishing is what actually creates content, so the // rules gate is re-checked here even though market entry already ran it. final store = widget.onboarding; if (store != null) { final ok = await ensureMarketRulesAccepted(context, store); if (!ok || !mounted) return; } 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: [ if (widget.savedSearches != null) SavedSearchesAction(store: widget.savedSearches!), 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, onOfferClosed: _reloadBlocked, savedSearches: widget.savedSearches, ), ), ); } } class MarketBody extends StatelessWidget { const MarketBody({ required this.onConfigure, this.onRetry, this.hasArea = true, this.selfPubkey, this.onOfferClosed, this.savedSearches, super.key, }); final VoidCallback onConfigure; /// Store for the "save this search" affordance; null → the button hides. final SavedSearchesStore? savedSearches; /// 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; /// Called after coming back from an offer's detail (which can block its /// author), so the visible list refreshes against the blocklist. final Future Function()? onOfferClosed; @override Widget build(BuildContext context) { final t = context.t; return BlocBuilder( builder: (context, state) { // A new user's first step is setting a zone — which works offline — so // that prompt wins over the connection error. if (!hasArea) { return _EmptyState( icon: Icons.place_outlined, title: t.market.setArea, body: t.market.setAreaBody, actionLabel: t.market.setUp, onAction: onConfigure, ); } 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 (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), // Offer to save the current search once it's worth alerting on // (some text typed or a chip active), never for the bare list. suffixIcon: savedSearches != null && (state.query.trim().isNotEmpty || state.hasActiveFilter) ? IconButton( key: const Key('market.saveSearch'), icon: const Icon( Icons.bookmark_add_outlined, color: seedGreen, ), tooltip: t.savedSearches.save, onPressed: () => _saveCurrentSearch( context, savedSearches!, state, ), ) : null, 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: '', ), ), ], ) : NotificationListener( // Page in older offers as the list nears its end, so a // large area streams in on demand rather than all at // once. The cubit guards against overlapping loads. onNotification: (n) { if (state.canLoadMore && !state.loadingMore && n.metrics.axis == Axis.vertical && n.metrics.extentAfter < 500) { cubit.loadNextPage(); } return false; }, child: ListView.separated( physics: const AlwaysScrollableScrollPhysics(), padding: const EdgeInsets.all(16), // A trailing spinner row while the next page loads. itemCount: visible.length + (state.loadingMore ? 1 : 0), separatorBuilder: (_, _) => const SizedBox(height: 12), itemBuilder: (context, i) { if (i >= visible.length) { return const Padding( padding: EdgeInsets.symmetric(vertical: 16), child: Center( child: CircularProgressIndicator(), ), ); } final o = visible[i]; final mine = o.authorPubkeyHex == selfPubkey; return _OfferCard( offer: o, mine: mine, onTap: () async { await context.push('/market/offer', extra: o); await onOfferClosed?.call(); }, ); }, ), ), ), ), ], ); }, ); } } /// Prompts for a name, then saves the current query + facets as a search the /// user will be alerted about. Offers already visible are seeded as "seen" so /// saving never immediately alerts about listings the user is looking at. Future _saveCurrentSearch( BuildContext context, SavedSearchesStore store, OffersState state, ) async { final t = context.t; final messenger = ScaffoldMessenger.of(context); final defaultLabel = state.query.trim(); final controller = TextEditingController(text: defaultLabel); final label = await showDialog( context: context, builder: (context) => AlertDialog( title: Text(t.savedSearches.save), content: TextField( controller: controller, autofocus: true, decoration: InputDecoration( labelText: t.savedSearches.nameLabel, hintText: t.savedSearches.namePlaceholder, ), onSubmitted: (v) => Navigator.of(context).pop(v), ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), child: Text(t.common.cancel), ), FilledButton( onPressed: () => Navigator.of(context).pop(controller.text), child: Text(t.common.save), ), ], ), ); if (label == null) return; // cancelled final trimmed = label.trim(); final search = SavedSearch( id: IdGen().newId(), label: trimmed.isEmpty ? t.savedSearches.title : trimmed, query: state.query.trim(), types: state.typeFilter, categories: state.categoryFilter, organicOnly: state.organicOnly, createdAt: DateTime.now().millisecondsSinceEpoch, ); await store.save( search, seedSeenKeys: [ for (final o in state.visibleOffers) SavedSearchesStore.keyOf(o), ], ); messenger.showSnackBar(SnackBar(content: Text(t.savedSearches.saved))); } /// 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) PlainFilterChip( key: const Key('market.filter.organic'), icon: Icons.eco, label: t.editVariety.organic, selected: state.organicOnly, onSelected: (_) => cubit.toggleOrganicOnly(), ), for (final type in types) PlainFilterChip( key: Key('market.filter.type.${type.name}'), label: offerTypeLabel(t, type), selected: state.typeFilter.contains(type), onSelected: (_) => cubit.toggleType(type), ), for (final category in categories) PlainFilterChip( key: Key('market.filter.category.$category'), label: category, selected: state.categoryFilter.contains(category), onSelected: (_) => cubit.toggleCategory(category), ), ]; return EdgeFade( child: 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, this.sharing}); final SocialSettings settings; final CoarseLocationProvider? location; final SharingSwitch? sharing; @override State<_ConfigSheet> createState() => _ConfigSheetState(); } class _ConfigSheetState extends State<_ConfigSheet> { final _area = TextEditingController(); /// Servers offered as toggles: the built-in defaults plus any the user has /// saved or added. [_selectedRelays] is the subset currently switched on. List _knownRelays = const []; Set _selectedRelays = {}; 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(); final saved = await widget.settings.relayUrls(); _selectedRelays = saved.toSet(); // Show the defaults plus anything the user saved, defaults first. _knownRelays = {...SocialSettings.defaultRelays, ...saved}.toList(); _precision = await widget.settings.searchPrecision(); if (mounted) setState(() => _loading = false); } Future _save() async { await widget.settings.setAreaGeohash(_area.text); await widget.settings.setRelayUrls(_selectedRelays.toList()); await widget.settings.setSearchPrecision(_precision); if (mounted) Navigator.of(context).pop(true); } /// The human-friendly server name — just the host, no `wss://` scheme or /// trailing slash (jargon stays out of the UI). String _relayLabel(String url) => url .trim() .replaceFirst(RegExp(r'^wss?://'), '') .replaceFirst(RegExp(r'/+$'), ''); /// Coerces user input into a `wss://` address, or null if it can't be one. /// Bare hosts get a `wss://` prefix so people needn't type the scheme. String? _normalizeRelay(String raw) { var s = raw.trim(); if (s.isEmpty) return null; if (!s.contains('://')) s = 'wss://$s'; final uri = Uri.tryParse(s); if (uri == null || (uri.scheme != 'wss' && uri.scheme != 'ws') || uri.host.isEmpty) { return null; } return s.replaceFirst(RegExp(r'/+$'), ''); } Future _addServer() async { final t = context.t; final controller = TextEditingController(); final url = await showDialog( context: context, builder: (dialogContext) { String? error; return StatefulBuilder( builder: (dialogContext, setLocal) => AlertDialog( title: Text(t.market.serversAdvanced), content: TextField( key: const Key('market.serverField'), controller: controller, autofocus: true, keyboardType: TextInputType.url, decoration: InputDecoration( labelText: t.market.serverAddress, hintText: 'wss://…', errorText: error, ), ), actions: [ TextButton( onPressed: () => Navigator.pop(dialogContext), child: Text(t.common.cancel), ), FilledButton( key: const Key('market.serverConfirm'), onPressed: () { final value = _normalizeRelay(controller.text); if (value == null) { setLocal(() => error = t.market.serverInvalid); } else { Navigator.pop(dialogContext, value); } }, child: Text(t.market.save), ), ], ), ); }, ); if (url == null || !mounted) return; setState(() { if (!_knownRelays.contains(url)) _knownRelays = [..._knownRelays, url]; _selectedRelays.add(url); }); } /// 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(); super.dispose(); } @override Widget build(BuildContext context) { final t = context.t; final hasArea = _area.text.trim().isNotEmpty; // SafeArea keeps the Save button above the system nav bar on edge-to-edge // devices; the viewInsets padding still lifts it above the keyboard. return SafeArea( top: false, child: 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: [ // The master switch: turning it off takes Tane fully // offline right away, not at the next launch. if (widget.sharing != null) ValueListenableBuilder( valueListenable: widget.sharing!.on, builder: (context, on, _) => SwitchListTile( key: const Key('market.sharingOn'), contentPadding: EdgeInsets.zero, dense: true, value: on, title: Text(t.market.sharingOnLabel), subtitle: Text( t.market.sharingOnHelp, style: const TextStyle( fontSize: 12, color: seedMuted, height: 1.3, ), ), onChanged: (want) => want ? widget.sharing!.enable() : widget.sharing!.disable(), ), ), 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), Align( alignment: AlignmentDirectional.centerStart, child: Text( t.market.serversLabel, style: const TextStyle( fontSize: 13, color: seedMuted, ), ), ), Padding( padding: const EdgeInsetsDirectional.only( top: 2, bottom: 4, ), child: Text( t.market.serversHelp, style: const TextStyle( fontSize: 12, color: seedMuted, height: 1.3, ), ), ), for (final url in _knownRelays) CheckboxListTile( key: Key('market.server.$url'), contentPadding: EdgeInsets.zero, dense: true, controlAffinity: ListTileControlAffinity.leading, value: _selectedRelays.contains(url), title: Text(_relayLabel(url)), onChanged: (on) => setState(() { if (on ?? false) { _selectedRelays.add(url); } else { _selectedRelays.remove(url); } }), ), Align( alignment: AlignmentDirectional.centerStart, child: TextButton.icon( key: const Key('market.addServer'), onPressed: _addServer, icon: const Icon(Icons.add, size: 18), label: Text(t.market.serversAdvanced), ), ), ListTile( key: const Key('market.blockedPeople'), contentPadding: EdgeInsets.zero, leading: const Icon(Icons.block, color: seedMuted), title: Text(t.block.manageTitle), trailing: const Icon(Icons.chevron_right), onTap: () => showModalBottomSheet( context: context, isScrollControlled: true, builder: (_) => BlockedPeopleSheet(settings: widget.settings), ), ), ], ), ), const SizedBox(height: 20), FilledButton( key: const Key('market.save'), onPressed: _save, child: Text(t.market.save), ), ], ), ), ), ); } }