Makes the coarse area human, per feedback that a typed code is jargon. - CoarseLocationProvider interface + geolocator-backed impl (BSD-3): low accuracy only, returns null on any denial/error (degrades quietly). Behind an interface so it's fakeable and the plugin stays at the composition root. - Config sheet: a 'Use my approximate location' button (shown only when a provider is wired) fills the area from the device position, reduced to a 5-char geohash via commons_core's Geohash — a precise fix never leaves the device. 'Couldn't get your location' on failure. - Threaded via TaneApp(location); main wires GeolocatorCoarseLocation. - Platform: ACCESS_COARSE_LOCATION (Android) + NSLocationWhenInUseUsageDescription (iOS), both scoped to the coarse-area use. - i18n en/es/pt. Widget test fills the area from a fake location. 8 tests green.
485 lines
15 KiB
Dart
485 lines
15 KiB
Dart
import 'package:commons_core/commons_core.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
|
|
import '../data/variety_repository.dart';
|
|
import '../i18n/strings.g.dart';
|
|
import '../services/coarse_location.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,
|
|
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;
|
|
|
|
@override
|
|
State<MarketScreen> createState() => _MarketScreenState();
|
|
}
|
|
|
|
class _MarketScreenState extends State<MarketScreen> {
|
|
OffersCubit? _cubit;
|
|
bool _loading = true;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_init();
|
|
}
|
|
|
|
Future<void> _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<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 {
|
|
final cubit = _cubit;
|
|
if (cubit == null || !cubit.isOnline) 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 count = await cubit.publishLots(
|
|
lots,
|
|
authorPubkeyHex: widget.social.publicKeyHex,
|
|
areaGeohash: area,
|
|
);
|
|
if (!mounted) return;
|
|
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)
|
|
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),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
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<OffersCubit, OffersState>(
|
|
builder: (context, state) {
|
|
if (!context.read<OffersCubit>().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, 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;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_load();
|
|
}
|
|
|
|
Future<void> _load() async {
|
|
_area.text = await widget.settings.areaGeohash();
|
|
_servers.text = (await widget.settings.relayUrls()).join('\n');
|
|
if (mounted) setState(() => _loading = false);
|
|
}
|
|
|
|
Future<void> _save() async {
|
|
await widget.settings.setAreaGeohash(_area.text);
|
|
await widget.settings.setRelayUrls(_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<void> _useLocation() async {
|
|
final provider = widget.location;
|
|
if (provider == null) return;
|
|
final messenger = ScaffoldMessenger.of(context);
|
|
final t = context.t;
|
|
final loc = await provider.currentCoarseLatLon();
|
|
if (!mounted) return;
|
|
if (loc == null) {
|
|
messenger.showSnackBar(SnackBar(content: Text(t.market.locationFailed)));
|
|
return;
|
|
}
|
|
setState(() => _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()))
|
|
: 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: const Icon(Icons.my_location, size: 18),
|
|
label: Text(t.market.useLocation),
|
|
onPressed: _useLocation,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
// The technical web address lives under progressive disclosure —
|
|
// most people won't have one, and jargon shouldn't greet them.
|
|
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: 14,
|
|
color: seedOnSurface,
|
|
),
|
|
),
|
|
children: [
|
|
TextField(
|
|
key: const Key('market.servers'),
|
|
controller: _servers,
|
|
minLines: 2,
|
|
maxLines: 4,
|
|
decoration: InputDecoration(
|
|
labelText: t.market.serversLabel,
|
|
helperText: t.market.serversHelp,
|
|
helperMaxLines: 4,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 20),
|
|
FilledButton(
|
|
key: const Key('market.save'),
|
|
onPressed: _save,
|
|
child: Text(t.market.save),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|