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:
parent
528b499209
commit
c696956ee2
8 changed files with 637 additions and 16 deletions
|
|
@ -8,12 +8,15 @@ import 'data/species_repository.dart';
|
||||||
import 'data/variety_repository.dart';
|
import 'data/variety_repository.dart';
|
||||||
import 'i18n/strings.g.dart';
|
import 'i18n/strings.g.dart';
|
||||||
import 'services/onboarding_store.dart';
|
import 'services/onboarding_store.dart';
|
||||||
|
import 'services/social_service.dart';
|
||||||
|
import 'services/social_settings.dart';
|
||||||
import 'state/inventory_cubit.dart';
|
import 'state/inventory_cubit.dart';
|
||||||
import 'state/variety_detail_cubit.dart';
|
import 'state/variety_detail_cubit.dart';
|
||||||
import 'ui/about_screen.dart';
|
import 'ui/about_screen.dart';
|
||||||
import 'ui/home_screen.dart';
|
import 'ui/home_screen.dart';
|
||||||
import 'ui/intro_screen.dart';
|
import 'ui/intro_screen.dart';
|
||||||
import 'ui/inventory_list_screen.dart';
|
import 'ui/inventory_list_screen.dart';
|
||||||
|
import 'ui/market_screen.dart';
|
||||||
import 'ui/settings_screen.dart';
|
import 'ui/settings_screen.dart';
|
||||||
import 'ui/theme.dart';
|
import 'ui/theme.dart';
|
||||||
import 'ui/variety_detail_screen.dart';
|
import 'ui/variety_detail_screen.dart';
|
||||||
|
|
@ -26,13 +29,26 @@ class TaneApp extends StatelessWidget {
|
||||||
required this.repository,
|
required this.repository,
|
||||||
required this.species,
|
required this.species,
|
||||||
required this.onboarding,
|
required this.onboarding,
|
||||||
|
this.social,
|
||||||
|
this.socialSettings,
|
||||||
this.showIntro = false,
|
this.showIntro = false,
|
||||||
super.key,
|
super.key,
|
||||||
}) : _router = _buildRouter(repository, onboarding, showIntro);
|
}) : _router = _buildRouter(
|
||||||
|
repository,
|
||||||
|
onboarding,
|
||||||
|
showIntro,
|
||||||
|
social,
|
||||||
|
socialSettings,
|
||||||
|
);
|
||||||
|
|
||||||
final VarietyRepository repository;
|
final VarietyRepository repository;
|
||||||
final SpeciesRepository species;
|
final SpeciesRepository species;
|
||||||
final OnboardingStore onboarding;
|
final OnboardingStore onboarding;
|
||||||
|
|
||||||
|
/// Block 2 social layer. Null until the social round is wired in `main`; when
|
||||||
|
/// null the market stays a "coming soon" card and `/market` is not routed.
|
||||||
|
final SocialService? social;
|
||||||
|
final SocialSettings? socialSettings;
|
||||||
final bool showIntro;
|
final bool showIntro;
|
||||||
final GoRouter _router;
|
final GoRouter _router;
|
||||||
|
|
||||||
|
|
@ -40,11 +56,23 @@ class TaneApp extends StatelessWidget {
|
||||||
VarietyRepository repository,
|
VarietyRepository repository,
|
||||||
OnboardingStore onboarding,
|
OnboardingStore onboarding,
|
||||||
bool showIntro,
|
bool showIntro,
|
||||||
|
SocialService? social,
|
||||||
|
SocialSettings? socialSettings,
|
||||||
) {
|
) {
|
||||||
return GoRouter(
|
return GoRouter(
|
||||||
initialLocation: showIntro ? '/intro' : '/',
|
initialLocation: showIntro ? '/intro' : '/',
|
||||||
routes: [
|
routes: [
|
||||||
GoRoute(path: '/', builder: (context, state) => const HomeScreen()),
|
GoRoute(
|
||||||
|
path: '/',
|
||||||
|
builder: (context, state) =>
|
||||||
|
HomeScreen(marketEnabled: social != null),
|
||||||
|
),
|
||||||
|
if (social != null && socialSettings != null)
|
||||||
|
GoRoute(
|
||||||
|
path: '/market',
|
||||||
|
builder: (context, state) =>
|
||||||
|
MarketScreen(social: social, settings: socialSettings),
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/intro',
|
path: '/intro',
|
||||||
builder: (context, state) => IntroScreen(
|
builder: (context, state) => IntroScreen(
|
||||||
|
|
|
||||||
|
|
@ -327,5 +327,23 @@
|
||||||
"cutting": { "singular": "cutting", "plural": "cuttings" },
|
"cutting": { "singular": "cutting", "plural": "cuttings" },
|
||||||
"grams": { "singular": "gram", "plural": "grams" },
|
"grams": { "singular": "gram", "plural": "grams" },
|
||||||
"count": { "singular": "seed", "plural": "seeds" }
|
"count": { "singular": "seed", "plural": "seeds" }
|
||||||
|
},
|
||||||
|
"market": {
|
||||||
|
"title": "Seeds near you",
|
||||||
|
"subtitle": "What others are sharing nearby",
|
||||||
|
"notSetUp": "Sharing isn't set up yet",
|
||||||
|
"notSetUpBody": "Add a community server and your area to see and share seeds nearby. Everything stays coarse — never your exact address.",
|
||||||
|
"setUp": "Set up sharing",
|
||||||
|
"searching": "Looking around your area…",
|
||||||
|
"empty": "No seeds shared near you yet",
|
||||||
|
"near": "Near you",
|
||||||
|
"configTitle": "Sharing setup",
|
||||||
|
"areaLabel": "Area code",
|
||||||
|
"areaHelp": "Kept coarse on purpose — roughly your zone, never an exact spot.",
|
||||||
|
"serversLabel": "Community servers",
|
||||||
|
"serversHelp": "One address per line — run by seed networks or collectives, not a company.",
|
||||||
|
"save": "Save",
|
||||||
|
"saved": "Saved",
|
||||||
|
"wanted": "Wanted"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -327,5 +327,23 @@
|
||||||
"cutting": { "singular": "esqueje", "plural": "esquejes" },
|
"cutting": { "singular": "esqueje", "plural": "esquejes" },
|
||||||
"grams": { "singular": "gramo", "plural": "gramos" },
|
"grams": { "singular": "gramo", "plural": "gramos" },
|
||||||
"count": { "singular": "semilla", "plural": "semillas" }
|
"count": { "singular": "semilla", "plural": "semillas" }
|
||||||
|
},
|
||||||
|
"market": {
|
||||||
|
"title": "Semillas cerca de ti",
|
||||||
|
"subtitle": "Lo que otras personas comparten cerca",
|
||||||
|
"notSetUp": "Aún no has configurado el compartir",
|
||||||
|
"notSetUpBody": "Añade un servidor comunitario y tu zona para ver y compartir semillas cerca. Todo se mantiene aproximado — nunca tu dirección exacta.",
|
||||||
|
"setUp": "Configurar el compartir",
|
||||||
|
"searching": "Buscando por tu zona…",
|
||||||
|
"empty": "Aún no hay semillas compartidas cerca de ti",
|
||||||
|
"near": "Cerca de ti",
|
||||||
|
"configTitle": "Configuración de compartir",
|
||||||
|
"areaLabel": "Código de zona",
|
||||||
|
"areaHelp": "Se mantiene aproximado a propósito — tu zona a grandes rasgos, nunca un punto exacto.",
|
||||||
|
"serversLabel": "Servidores comunitarios",
|
||||||
|
"serversHelp": "Una dirección por línea — de redes de semillas o colectivos, no de una empresa.",
|
||||||
|
"save": "Guardar",
|
||||||
|
"saved": "Guardado",
|
||||||
|
"wanted": "Busco"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -323,5 +323,23 @@
|
||||||
"cutting": { "singular": "estaca", "plural": "estacas" },
|
"cutting": { "singular": "estaca", "plural": "estacas" },
|
||||||
"grams": { "singular": "grama", "plural": "gramas" },
|
"grams": { "singular": "grama", "plural": "gramas" },
|
||||||
"count": { "singular": "semente", "plural": "sementes" }
|
"count": { "singular": "semente", "plural": "sementes" }
|
||||||
|
},
|
||||||
|
"market": {
|
||||||
|
"title": "Sementes perto de ti",
|
||||||
|
"subtitle": "O que outras pessoas partilham por perto",
|
||||||
|
"notSetUp": "Ainda não configuraste a partilha",
|
||||||
|
"notSetUpBody": "Adiciona um servidor comunitário e a tua zona para ver e partilhar sementes por perto. Tudo fica aproximado — nunca a tua morada exata.",
|
||||||
|
"setUp": "Configurar a partilha",
|
||||||
|
"searching": "A procurar pela tua zona…",
|
||||||
|
"empty": "Ainda não há sementes partilhadas perto de ti",
|
||||||
|
"near": "Perto de ti",
|
||||||
|
"configTitle": "Configuração de partilha",
|
||||||
|
"areaLabel": "Código de zona",
|
||||||
|
"areaHelp": "Mantém-se aproximado de propósito — a tua zona em geral, nunca um ponto exato.",
|
||||||
|
"serversLabel": "Servidores comunitários",
|
||||||
|
"serversHelp": "Um endereço por linha — de redes de sementes ou coletivos, não de uma empresa.",
|
||||||
|
"save": "Guardar",
|
||||||
|
"saved": "Guardado",
|
||||||
|
"wanted": "Procuro"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ import 'data/variety_repository.dart';
|
||||||
import 'di/injector.dart';
|
import 'di/injector.dart';
|
||||||
import 'i18n/strings.g.dart';
|
import 'i18n/strings.g.dart';
|
||||||
import 'services/onboarding_store.dart';
|
import 'services/onboarding_store.dart';
|
||||||
|
import 'services/social_service.dart';
|
||||||
|
import 'services/social_settings.dart';
|
||||||
|
|
||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
@ -18,6 +20,8 @@ Future<void> main() async {
|
||||||
repository: getIt<VarietyRepository>(),
|
repository: getIt<VarietyRepository>(),
|
||||||
species: getIt<SpeciesRepository>(),
|
species: getIt<SpeciesRepository>(),
|
||||||
onboarding: onboarding,
|
onboarding: onboarding,
|
||||||
|
social: getIt<SocialService>(),
|
||||||
|
socialSettings: getIt<SocialSettings>(),
|
||||||
showIntro: !await onboarding.introSeen(),
|
showIntro: !await onboarding.introSeen(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,11 @@ import 'theme.dart';
|
||||||
/// to action and "Open market" (Block 2) as a disabled outlined card. The
|
/// to action and "Open market" (Block 2) as a disabled outlined card. The
|
||||||
/// hamburger opens [AppDrawer].
|
/// hamburger opens [AppDrawer].
|
||||||
class HomeScreen extends StatelessWidget {
|
class HomeScreen extends StatelessWidget {
|
||||||
const HomeScreen({super.key});
|
const HomeScreen({this.marketEnabled = false, super.key});
|
||||||
|
|
||||||
|
/// When the Block 2 social layer is wired, the market becomes a live
|
||||||
|
/// destination; otherwise it stays a disabled "coming soon" card.
|
||||||
|
final bool marketEnabled;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|
@ -77,10 +81,14 @@ class HomeScreen extends StatelessWidget {
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_OutlinedMenuCard(
|
_OutlinedMenuCard(
|
||||||
|
key: const Key('home.market'),
|
||||||
icon: Icons.storefront_outlined,
|
icon: Icons.storefront_outlined,
|
||||||
label: t.home.openMarket,
|
label: t.home.openMarket,
|
||||||
subtitle: t.home.openMarketSubtitle,
|
subtitle: t.home.openMarketSubtitle,
|
||||||
tag: t.common.comingSoon,
|
tag: marketEnabled ? null : t.common.comingSoon,
|
||||||
|
onTap: marketEnabled
|
||||||
|
? () => context.go('/market')
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -151,17 +159,24 @@ class _OutlinedMenuCard extends StatelessWidget {
|
||||||
required this.icon,
|
required this.icon,
|
||||||
required this.label,
|
required this.label,
|
||||||
required this.subtitle,
|
required this.subtitle,
|
||||||
required this.tag,
|
this.tag,
|
||||||
|
this.onTap,
|
||||||
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
final String label;
|
final String label;
|
||||||
final String subtitle;
|
final String subtitle;
|
||||||
final String tag;
|
|
||||||
|
/// A small trailing tag (e.g. "coming soon"); omitted for live cards.
|
||||||
|
final String? tag;
|
||||||
|
|
||||||
|
/// When set, the card is tappable; otherwise it reads as disabled.
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Container(
|
final card = Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(18),
|
borderRadius: BorderRadius.circular(18),
|
||||||
|
|
@ -183,18 +198,31 @@ class _OutlinedMenuCard extends StatelessWidget {
|
||||||
subtitleColor: seedMuted,
|
subtitleColor: seedMuted,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (tag != null)
|
||||||
Text(
|
Text(
|
||||||
tag.toUpperCase(),
|
tag!.toUpperCase(),
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: Color(0xFFB3BDA8),
|
color: Color(0xFFB3BDA8),
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
letterSpacing: 0.5,
|
letterSpacing: 0.5,
|
||||||
),
|
),
|
||||||
),
|
)
|
||||||
|
else if (onTap != null)
|
||||||
|
const Icon(Icons.chevron_right, color: seedMuted),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
if (onTap == null) return card;
|
||||||
|
return Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
borderRadius: BorderRadius.circular(18),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(18),
|
||||||
|
child: card,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
384
apps/app_seeds/lib/ui/market_screen.dart
Normal file
384
apps/app_seeds/lib/ui/market_screen.dart
Normal 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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
123
apps/app_seeds/test/ui/market_screen_test.dart
Normal file
123
apps/app_seeds/test/ui/market_screen_test.dart
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:commons_core/commons_core.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:tane/i18n/strings.g.dart';
|
||||||
|
import 'package:tane/services/social_service.dart';
|
||||||
|
import 'package:tane/services/social_settings.dart';
|
||||||
|
import 'package:tane/state/offers_cubit.dart';
|
||||||
|
import 'package:tane/ui/market_screen.dart';
|
||||||
|
|
||||||
|
import '../support/test_support.dart';
|
||||||
|
|
||||||
|
/// In-memory offer transport: discover matches by geohash prefix.
|
||||||
|
class FakeOfferTransport implements OfferTransport {
|
||||||
|
final List<Offer> offers;
|
||||||
|
FakeOfferTransport(this.offers);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<PublishResult> publish(Offer offer) async =>
|
||||||
|
PublishResult(accepted: true, transportRef: offer.id);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<Offer> discover(DiscoveryQuery query) {
|
||||||
|
final c = StreamController<Offer>();
|
||||||
|
for (final o in offers) {
|
||||||
|
if (o.approxGeohash.startsWith(query.geohashPrefix)) c.add(o);
|
||||||
|
}
|
||||||
|
return c.stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> retract(String offerId) async {}
|
||||||
|
@override
|
||||||
|
Future<void> close() async {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _wrapBody(OffersCubit cubit) {
|
||||||
|
LocaleSettings.setLocaleSync(AppLocale.en);
|
||||||
|
return TranslationProvider(
|
||||||
|
child: MaterialApp(
|
||||||
|
locale: AppLocale.en.flutterLocale,
|
||||||
|
supportedLocales: AppLocaleUtils.supportedLocales,
|
||||||
|
localizationsDelegates: const [
|
||||||
|
GlobalMaterialLocalizations.delegate,
|
||||||
|
GlobalWidgetsLocalizations.delegate,
|
||||||
|
GlobalCupertinoLocalizations.delegate,
|
||||||
|
],
|
||||||
|
home: Scaffold(
|
||||||
|
body: BlocProvider.value(
|
||||||
|
value: cubit,
|
||||||
|
child: MarketBody(onConfigure: () {}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Offer _offer(String id, String summary, OfferType type) => Offer(
|
||||||
|
id: id,
|
||||||
|
authorPubkeyHex: 'ab' * 32,
|
||||||
|
summary: summary,
|
||||||
|
type: type,
|
||||||
|
approxGeohash: 'sp3e9',
|
||||||
|
);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('renders discovered offers with their type', (tester) async {
|
||||||
|
final cubit = OffersCubit(FakeOfferTransport([
|
||||||
|
_offer('1', 'Tomate rosa de Barbastro', OfferType.gift),
|
||||||
|
_offer('2', 'Judía del ganxet', OfferType.exchange),
|
||||||
|
]));
|
||||||
|
addTearDown(cubit.close);
|
||||||
|
|
||||||
|
await tester.pumpWidget(_wrapBody(cubit));
|
||||||
|
await cubit.discover('sp3');
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Tomate rosa de Barbastro'), findsOneWidget);
|
||||||
|
expect(find.text('Judía del ganxet'), findsOneWidget);
|
||||||
|
expect(find.text('Near you'), findsWidgets);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('offline (no transport) shows the "set up sharing" prompt',
|
||||||
|
(tester) async {
|
||||||
|
final cubit = OffersCubit(null);
|
||||||
|
addTearDown(cubit.close);
|
||||||
|
|
||||||
|
await tester.pumpWidget(_wrapBody(cubit));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text("Sharing isn't set up yet"), findsOneWidget);
|
||||||
|
expect(find.text('Set up sharing'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('MarketScreen with no relays configured degrades to offline',
|
||||||
|
(tester) async {
|
||||||
|
final social = await SocialService.fromRootSeedHex('00' * 32);
|
||||||
|
final settings = SocialSettings(InMemorySecretStore());
|
||||||
|
LocaleSettings.setLocaleSync(AppLocale.en);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
TranslationProvider(
|
||||||
|
child: MaterialApp(
|
||||||
|
locale: AppLocale.en.flutterLocale,
|
||||||
|
supportedLocales: AppLocaleUtils.supportedLocales,
|
||||||
|
localizationsDelegates: const [
|
||||||
|
GlobalMaterialLocalizations.delegate,
|
||||||
|
GlobalWidgetsLocalizations.delegate,
|
||||||
|
GlobalCupertinoLocalizations.delegate,
|
||||||
|
],
|
||||||
|
home: MarketScreen(social: social, settings: settings),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Seeds near you'), findsOneWidget); // app bar title
|
||||||
|
expect(find.text('Set up sharing'), findsOneWidget); // offline prompt
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue