876 lines
30 KiB
Dart
876 lines
30 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();
|
|
final _servers = TextEditingController();
|
|
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();
|
|
_servers.text = (await widget.settings.relayUrls()).join('\n');
|
|
_precision = await widget.settings.searchPrecision();
|
|
if (mounted) setState(() => _loading = false);
|
|
}
|
|
|
|
Future<void> _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<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();
|
|
_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<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),
|
|
TextField(
|
|
key: const Key('market.servers'),
|
|
controller: _servers,
|
|
minLines: 1,
|
|
maxLines: 3,
|
|
decoration: InputDecoration(
|
|
labelText: t.market.serversLabel,
|
|
helperText: t.market.serversHelp,
|
|
helperMaxLines: 2,
|
|
),
|
|
),
|
|
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),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|