feat(block2): market screen — discover seeds near you (offers UI)

Wires the 'coming soon' market card to a real discovery screen (mockups 04/05):
- MarketScreen: opens an OfferTransport lazily; local-first, so it degrades to a
  human 'set up sharing' prompt when no relay/area is set or the net is down.
- Discovers offers by the saved coarse area, lists them with human reciprocity
  labels (gift/swap/for sale), 'near you' (never exact location), price only for
  sales. Config sheet to set area + community servers (no bundled public relays).
- Threaded via TaneApp(social, socialSettings) — null keeps the card 'coming
  soon' (so Block 1 tests pass unchanged); main wires it live.
- i18n en/es/pt (market.*), reuses share.* for type labels.

3 market widget tests + home tests green; app_seeds analyzes clean.
This commit is contained in:
vjrj 2026-07-10 03:05:12 +02:00
parent 801a846d6e
commit 2c358ec33a
8 changed files with 637 additions and 16 deletions

View file

@ -0,0 +1,384 @@
import 'package:commons_core/commons_core.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../i18n/strings.g.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, super.key});
final SocialService social;
final SocialSettings settings;
@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),
);
if (changed == true) await _init();
}
@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,
),
],
),
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});
final SocialSettings settings;
@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);
}
@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: 16),
TextField(
key: const Key('market.area'),
controller: _area,
decoration: InputDecoration(
labelText: t.market.areaLabel,
helperText: t.market.areaHelp,
helperMaxLines: 3,
),
),
const SizedBox(height: 16),
TextField(
key: const Key('market.servers'),
controller: _servers,
minLines: 2,
maxLines: 4,
decoration: InputDecoration(
labelText: t.market.serversLabel,
helperText: t.market.serversHelp,
helperMaxLines: 3,
),
),
const SizedBox(height: 20),
FilledButton(
key: const Key('market.save'),
onPressed: _save,
child: Text(t.market.save),
),
],
),
);
}
}