tane/apps/app_seeds/lib/ui/market_screen.dart
vjrj 68b336ec93 feat(block2): default relays + pick-from-list servers (works out of the box)
Feedback: the blank 'web address' field was unusable — nobody knows what to put,
so the market never worked at first launch.
- SocialSettings ships default public relays (nos.lol, relay.damus.io), applied
  automatically for new users so the market works day one. An explicitly empty
  choice still turns the network off (privacy). Exposure is minimal: offers are
  opt-in and carry only a coarse geohash.
- Sharing setup now shows the servers as a short PICK-LIST (checkboxes, pre-
  selected) instead of a blank field; a community's own server goes under a small
  'add another' escape hatch. Intro reworded — no need to know a community.
- Tests updated (defaults) and pinned offline (setRelayUrls([])) so widget tests
  don't hit the network. 22 green, analyzer clean.
2026-07-10 11:19:49 +02:00

605 lines
19 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/offer_outbox.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,
this.outbox,
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;
/// 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;
@override
void initState() {
super.initState();
_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.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) {
// 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) 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) 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
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 || widget.outbox != null))
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,
selfPubkey: widget.social.publicKeyHex,
),
),
);
}
}
class MarketBody extends StatelessWidget {
const MarketBody({
required this.onConfigure,
this.selfPubkey,
super.key,
});
final VoidCallback onConfigure;
/// This user's own pubkey, so we don't offer to message our own listings.
final String? selfPubkey;
@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) {
final o = state.offers[i];
return _OfferCard(
offer: o,
onContact: o.authorPubkeyHex == selfPubkey
? null
: () => context.go('/chat/${o.authorPubkeyHex}'),
);
},
);
},
);
}
}
/// 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.onContact});
final Offer offer;
/// Opens a private chat with the author; null for our own listings.
final VoidCallback? onContact;
@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)),
const Spacer(),
if (offer.type == OfferType.sale && offer.priceAmount != null)
Text(
'${offer.priceAmount} ${offer.priceCurrency ?? ''}'.trim(),
style: const TextStyle(
color: seedOnSurface,
fontWeight: FontWeight.w600,
),
),
if (onContact != null)
TextButton.icon(
onPressed: onContact,
icon: const Icon(Icons.chat_bubble_outline, size: 16),
label: Text(t.market.contact),
),
],
),
],
),
);
}
}
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();
final Set<String> _selectedRelays = {};
bool _loading = true;
bool _locationBusy = false;
String? _locationError;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
_area.text = await widget.settings.areaGeohash();
final current = await widget.settings.relayUrls();
final presetUrls = SocialSettings.presets.map((p) => p.url).toSet();
_selectedRelays
..clear()
..addAll(current.where(presetUrls.contains));
// Any relays that aren't presets go in the "add another" field.
_servers.text = current.where((u) => !presetUrls.contains(u)).join('\n');
if (mounted) setState(() => _loading = false);
}
Future<void> _save() async {
await widget.settings.setAreaGeohash(_area.text);
await widget.settings.setRelayUrls([
..._selectedRelays,
..._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 || _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;
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: 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: _locationBusy
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.my_location, size: 18),
label: Text(t.market.useLocation),
onPressed: _locationBusy ? null : _useLocation,
),
),
if (_locationError != null)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
_locationError!,
style: const TextStyle(color: Color(0xFFB3261E), fontSize: 12),
),
),
const SizedBox(height: 16),
// Pick from a short list of shared servers — pre-selected so it
// works out of the box; no jargon-y blank field to puzzle over.
Text(
t.market.serversLabel,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: seedOnSurface,
),
),
Text(
t.market.serversHelp,
style: const TextStyle(color: seedMuted, fontSize: 12, height: 1.3),
),
for (final preset in SocialSettings.presets)
CheckboxListTile(
key: Key('market.relay.${preset.name}'),
contentPadding: EdgeInsets.zero,
dense: true,
controlAffinity: ListTileControlAffinity.leading,
value: _selectedRelays.contains(preset.url),
title: Text(preset.name),
subtitle: Text(preset.url,
style: const TextStyle(fontSize: 11, color: seedMuted)),
onChanged: (checked) => setState(() {
if (checked ?? false) {
_selectedRelays.add(preset.url);
} else {
_selectedRelays.remove(preset.url);
}
}),
),
// A rarely-needed escape hatch for a community's own server.
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: 13, color: seedMuted),
),
children: [
TextField(
key: const Key('market.servers'),
controller: _servers,
minLines: 1,
maxLines: 3,
decoration: const InputDecoration(
hintText: 'wss://…',
),
),
],
),
),
const SizedBox(height: 20),
FilledButton(
key: const Key('market.save'),
onPressed: _save,
child: Text(t.market.save),
),
],
),
),
);
}
}