tane/apps/app_seeds/lib/ui/market_screen.dart
vjrj 1ce32c2b5c feat: cover seedlings (plantón) in copy + legal, backup cadence, server picker
Beta-tester feedback triage. The data model already had LotType.seedling as
first class; the gaps were copy and UX.

- Seedlings/plantones: broaden landing hero (EN/ES), in-app legal notice, and
  all legal texts (site + docs masters) from seeds-only to "seeds and
  seedlings", with a live-plant phytosanitary/transport caveat. Seeds stay the
  hero — targeted, not a blanket rename.
- Backups: settings line now states the 7-day cadence explicitly; cadence lives
  in AutoBackupService.backupInterval as the single source for text and schedule.
- Community servers: replace the manual wss:// text box with a checklist of the
  known servers (defaults visible/toggleable) plus an "add server" affordance
  with basic validation. No jargon in the UI.

i18n across en/es/pt/fr/de/ast (ja falls back). Tests updated + a new
server-picker widget test.
2026-07-15 15:57:23 +02:00

985 lines
35 KiB
Dart

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 '../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 '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,
super.key,
});
final SocialService social;
final SocialSettings settings;
/// 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 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<MarketScreen> createState() => _MarketScreenState();
}
class _MarketScreenState extends State<MarketScreen> {
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<void> _start() async {
final store = widget.onboarding;
if (store != null && !await store.marketRulesAccepted()) {
// 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);
if (!ok) {
if (mounted) context.pop();
return;
}
}
await _init();
}
Future<void> _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<VarietyRepository>() : 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;
}
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));
}
}
}
/// 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<void> _reloadBlocked() async {
final cubit = _cubit;
if (cubit == null) return;
cubit.setBlockedAuthors(await widget.settings.blockedPubkeys());
cubit.setHiddenOffers(await widget.settings.hiddenOfferKeys());
}
Future<void> _openConfig() async {
final changed = await showModalBottomSheet<bool>(
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<void> _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<VarietyRepository>();
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,
onOfferClosed: _reloadBlocked,
),
),
);
}
}
class MarketBody extends StatelessWidget {
const MarketBody({
required this.onConfigure,
this.onRetry,
this.hasArea = true,
this.selfPubkey,
this.onOfferClosed,
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;
/// Called after coming back from an offer's detail (which can block its
/// author), so the visible list refreshes against the blocklist.
final Future<void> Function()? onOfferClosed;
@override
Widget build(BuildContext context) {
final t = context.t;
return BlocBuilder<OffersCubit, OffersState>(
builder: (context, state) {
if (!context.read<OffersCubit>().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<OffersCubit>();
// 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<void> 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: () async {
await context.push('/market/offer', extra: o);
await onOfferClosed?.call();
},
);
},
),
),
),
],
);
},
);
}
}
/// 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<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)
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});
final SocialSettings settings;
final CoarseLocationProvider? location;
@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<String> _knownRelays = const [];
Set<String> _selectedRelays = <String>{};
bool _loading = true;
bool _locationBusy = false;
String? _locationError;
int _precision = SocialSettings.defaultSearchPrecision;
@override
void initState() {
super.initState();
_load();
}
Future<void> _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 = <String>{...SocialSettings.defaultRelays, ...saved}.toList();
_precision = await widget.settings.searchPrecision();
if (mounted) setState(() => _loading = false);
}
Future<void> _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<void> _addServer() async {
final t = context.t;
final controller = TextEditingController();
final url = await showDialog<String>(
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<void> _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;
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<int>(
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),
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<void>(
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),
),
],
),
),
);
}
}