fix(block2): sharing config UX + discovery + labels (raw feedback batch)

From on-device feedback:
- Area is no longer shown as a raw geohash 'name'. 'Use my location' is the
  primary action; a human status line says whether your area is set (coarse,
  never exact). The geohash code + the relay servers moved under 'Advanced'
  (code field labelled 'a code like sp3e9 — not a place name'), which also
  brings back node/relay selection that had been fully hidden.
- Location denial no longer yanks you into system settings — returns quietly
  with an actionable inline message.
- Discovery no longer spins forever when nobody's sharing nearby: a timeout
  resolves 'searching' to the empty state.
- Home card 'Open market' -> 'Market' + 'Discover and share seeds nearby'
  (the word 'open' confused).

i18n en/es/pt; analyzer clean; the use-my-location test asserts the status flip.
This commit is contained in:
vjrj 2026-07-10 13:32:11 +02:00
parent d7136ec2c2
commit 5f87ad2d56
11 changed files with 208 additions and 90 deletions

View file

@ -12,8 +12,8 @@
},
"home": {
"tagline": "Share and grow local seeds",
"openMarket": "Open market",
"openMarketSubtitle": "Browse and exchange",
"openMarket": "Market",
"openMarketSubtitle": "Discover and share seeds nearby",
"yourInventory": "Your inventory",
"yourInventorySubtitle": "Manage your seeds"
},
@ -348,8 +348,13 @@
"setupIntro": "Sharing with people nearby is optional. Just set your rough area — you're already connected to shared community servers so people can find what you offer, with no company in the middle.",
"areaLabel": "Your area",
"areaHelp": "Kept rough on purpose — your zone, never an exact spot.",
"areaSet": "Your area is set — kept coarse, never your exact spot",
"areaNotSet": "Area not set yet — use your location, or add a code under Advanced",
"advanced": "Advanced",
"areaCodeLabel": "Area code",
"areaCodeHint": "A short code like sp3e9 — not a place name",
"serversLabel": "Community servers",
"serversHelp": "Pick one or more shared servers. Anyone can use them — or add your own community's below.",
"serversHelp": "One address per line (wss://…). Leave the defaults if unsure.",
"serversAdvanced": "Add another server",
"save": "Save",
"saved": "Saved",

View file

@ -12,8 +12,8 @@
},
"home": {
"tagline": "Comparte y cultiva semillas locales",
"openMarket": "Mercado abierto",
"openMarketSubtitle": "Explora e intercambia",
"openMarket": "Mercado",
"openMarketSubtitle": "Descubre y comparte semillas cerca",
"yourInventory": "Tu inventario",
"yourInventorySubtitle": "Gestiona tus semillas"
},
@ -348,8 +348,13 @@
"setupIntro": "Compartir con gente cercana es opcional. Solo indica tu zona aproximada — ya estás conectado a servidores comunitarios compartidos para que otras personas encuentren lo que ofreces, sin ninguna empresa en medio.",
"areaLabel": "Tu zona",
"areaHelp": "Se mantiene aproximado a propósito — tu zona, nunca un punto exacto.",
"areaSet": "Tu zona está puesta — aproximada, nunca tu punto exacto",
"areaNotSet": "Zona sin definir — usa tu ubicación, o añade un código en Avanzado",
"advanced": "Avanzado",
"areaCodeLabel": "Código de zona",
"areaCodeHint": "Un código corto como sp3e9 — no un nombre de lugar",
"serversLabel": "Servidores de la comunidad",
"serversHelp": "Elige uno o más servidores compartidos. Cualquiera puede usarlos — o añade abajo el de tu comunidad.",
"serversHelp": "Una dirección por línea (wss://…). Déjalo así si no sabes.",
"serversAdvanced": "Añadir otro servidor",
"save": "Guardar",
"saved": "Guardado",

View file

@ -12,8 +12,8 @@
},
"home": {
"tagline": "Partilha e cultiva sementes locais",
"openMarket": "Mercado aberto",
"openMarketSubtitle": "Descobre e troca",
"openMarket": "Mercado",
"openMarketSubtitle": "Descobre e partilha sementes por perto",
"yourInventory": "O teu inventário",
"yourInventorySubtitle": "Gere as tuas sementes"
},
@ -344,8 +344,13 @@
"setupIntro": "Partilhar com pessoas por perto é opcional. Basta indicar a tua zona aproximada — já estás ligado a servidores comunitários partilhados para que outras pessoas encontrem o que ofereces, sem nenhuma empresa no meio.",
"areaLabel": "A tua zona",
"areaHelp": "Mantém-se aproximado de propósito — a tua zona, nunca um ponto exato.",
"areaSet": "A tua zona está definida — aproximada, nunca o teu ponto exato",
"areaNotSet": "Zona por definir — usa a tua localização, ou adiciona um código em Avançado",
"advanced": "Avançado",
"areaCodeLabel": "Código de zona",
"areaCodeHint": "Um código curto como sp3e9 — não um nome de lugar",
"serversLabel": "Servidores da comunidade",
"serversHelp": "Escolhe um ou mais servidores partilhados. Qualquer pessoa os pode usar — ou adiciona abaixo o da tua comunidade.",
"serversHelp": "Um endereço por linha (wss://…). Deixa assim se não souberes.",
"serversAdvanced": "Adicionar outro servidor",
"save": "Guardar",
"saved": "Guardado",

View file

@ -4,9 +4,9 @@
/// To regenerate, run: `dart run slang`
///
/// Locales: 3
/// Strings: 1043 (347 per locale)
/// Strings: 1058 (352 per locale)
///
/// Built on 2026-07-10 at 11:05 UTC
/// Built on 2026-07-10 at 11:31 UTC
// coverage:ignore-file
// ignore_for_file: type=lint, unused_import

View file

@ -126,11 +126,11 @@ class Translations$home$en {
/// en: 'Share and grow local seeds'
String get tagline => 'Share and grow local seeds';
/// en: 'Open market'
String get openMarket => 'Open market';
/// en: 'Market'
String get openMarket => 'Market';
/// en: 'Browse and exchange'
String get openMarketSubtitle => 'Browse and exchange';
/// en: 'Discover and share seeds nearby'
String get openMarketSubtitle => 'Discover and share seeds nearby';
/// en: 'Your inventory'
String get yourInventory => 'Your inventory';
@ -1107,11 +1107,26 @@ class Translations$market$en {
/// en: 'Kept rough on purpose — your zone, never an exact spot.'
String get areaHelp => 'Kept rough on purpose — your zone, never an exact spot.';
/// en: 'Your area is set — kept coarse, never your exact spot'
String get areaSet => 'Your area is set — kept coarse, never your exact spot';
/// en: 'Area not set yet — use your location, or add a code under Advanced'
String get areaNotSet => 'Area not set yet — use your location, or add a code under Advanced';
/// en: 'Advanced'
String get advanced => 'Advanced';
/// en: 'Area code'
String get areaCodeLabel => 'Area code';
/// en: 'A short code like sp3e9 — not a place name'
String get areaCodeHint => 'A short code like sp3e9 — not a place name';
/// en: 'Community servers'
String get serversLabel => 'Community servers';
/// en: 'Pick one or more shared servers. Anyone can use them — or add your own community's below.'
String get serversHelp => 'Pick one or more shared servers. Anyone can use them — or add your own community\'s below.';
/// en: 'One address per line (wss://…). Leave the defaults if unsure.'
String get serversHelp => 'One address per line (wss://…). Leave the defaults if unsure.';
/// en: 'Add another server'
String get serversAdvanced => 'Add another server';
@ -1726,8 +1741,8 @@ extension on Translations {
'common.type' => 'Type',
'common.comingSoon' => 'Coming soon',
'home.tagline' => 'Share and grow local seeds',
'home.openMarket' => 'Open market',
'home.openMarketSubtitle' => 'Browse and exchange',
'home.openMarket' => 'Market',
'home.openMarketSubtitle' => 'Discover and share seeds nearby',
'home.yourInventory' => 'Your inventory',
'home.yourInventorySubtitle' => 'Manage your seeds',
'photo.camera' => 'Take a photo',
@ -2028,8 +2043,13 @@ extension on Translations {
'market.setupIntro' => 'Sharing with people nearby is optional. Just set your rough area — you\'re already connected to shared community servers so people can find what you offer, with no company in the middle.',
'market.areaLabel' => 'Your area',
'market.areaHelp' => 'Kept rough on purpose — your zone, never an exact spot.',
'market.areaSet' => 'Your area is set — kept coarse, never your exact spot',
'market.areaNotSet' => 'Area not set yet — use your location, or add a code under Advanced',
'market.advanced' => 'Advanced',
'market.areaCodeLabel' => 'Area code',
'market.areaCodeHint' => 'A short code like sp3e9 — not a place name',
'market.serversLabel' => 'Community servers',
'market.serversHelp' => 'Pick one or more shared servers. Anyone can use them — or add your own community\'s below.',
'market.serversHelp' => 'One address per line (wss://…). Leave the defaults if unsure.',
'market.serversAdvanced' => 'Add another server',
'market.save' => 'Save',
'market.saved' => 'Saved',

View file

@ -108,8 +108,8 @@ class _Translations$home$es extends Translations$home$en {
// Translations
@override String get tagline => 'Comparte y cultiva semillas locales';
@override String get openMarket => 'Mercado abierto';
@override String get openMarketSubtitle => 'Explora e intercambia';
@override String get openMarket => 'Mercado';
@override String get openMarketSubtitle => 'Descubre y comparte semillas cerca';
@override String get yourInventory => 'Tu inventario';
@override String get yourInventorySubtitle => 'Gestiona tus semillas';
}
@ -622,8 +622,13 @@ class _Translations$market$es extends Translations$market$en {
@override String get setupIntro => 'Compartir con gente cercana es opcional. Solo indica tu zona aproximada — ya estás conectado a servidores comunitarios compartidos para que otras personas encuentren lo que ofreces, sin ninguna empresa en medio.';
@override String get areaLabel => 'Tu zona';
@override String get areaHelp => 'Se mantiene aproximado a propósito — tu zona, nunca un punto exacto.';
@override String get areaSet => 'Tu zona está puesta — aproximada, nunca tu punto exacto';
@override String get areaNotSet => 'Zona sin definir — usa tu ubicación, o añade un código en Avanzado';
@override String get advanced => 'Avanzado';
@override String get areaCodeLabel => 'Código de zona';
@override String get areaCodeHint => 'Un código corto como sp3e9 — no un nombre de lugar';
@override String get serversLabel => 'Servidores de la comunidad';
@override String get serversHelp => 'Elige uno o más servidores compartidos. Cualquiera puede usarlos — o añade abajo el de tu comunidad.';
@override String get serversHelp => 'Una dirección por línea (wss://…). Déjalo así si no sabes.';
@override String get serversAdvanced => 'Añadir otro servidor';
@override String get save => 'Guardar';
@override String get saved => 'Guardado';
@ -1048,8 +1053,8 @@ extension on TranslationsEs {
'common.type' => 'Tipo',
'common.comingSoon' => 'Pronto',
'home.tagline' => 'Comparte y cultiva semillas locales',
'home.openMarket' => 'Mercado abierto',
'home.openMarketSubtitle' => 'Explora e intercambia',
'home.openMarket' => 'Mercado',
'home.openMarketSubtitle' => 'Descubre y comparte semillas cerca',
'home.yourInventory' => 'Tu inventario',
'home.yourInventorySubtitle' => 'Gestiona tus semillas',
'photo.camera' => 'Hacer una foto',
@ -1350,8 +1355,13 @@ extension on TranslationsEs {
'market.setupIntro' => 'Compartir con gente cercana es opcional. Solo indica tu zona aproximada — ya estás conectado a servidores comunitarios compartidos para que otras personas encuentren lo que ofreces, sin ninguna empresa en medio.',
'market.areaLabel' => 'Tu zona',
'market.areaHelp' => 'Se mantiene aproximado a propósito — tu zona, nunca un punto exacto.',
'market.areaSet' => 'Tu zona está puesta — aproximada, nunca tu punto exacto',
'market.areaNotSet' => 'Zona sin definir — usa tu ubicación, o añade un código en Avanzado',
'market.advanced' => 'Avanzado',
'market.areaCodeLabel' => 'Código de zona',
'market.areaCodeHint' => 'Un código corto como sp3e9 — no un nombre de lugar',
'market.serversLabel' => 'Servidores de la comunidad',
'market.serversHelp' => 'Elige uno o más servidores compartidos. Cualquiera puede usarlos — o añade abajo el de tu comunidad.',
'market.serversHelp' => 'Una dirección por línea (wss://…). Déjalo así si no sabes.',
'market.serversAdvanced' => 'Añadir otro servidor',
'market.save' => 'Guardar',
'market.saved' => 'Guardado',

View file

@ -108,8 +108,8 @@ class _Translations$home$pt extends Translations$home$en {
// Translations
@override String get tagline => 'Partilha e cultiva sementes locais';
@override String get openMarket => 'Mercado aberto';
@override String get openMarketSubtitle => 'Descobre e troca';
@override String get openMarket => 'Mercado';
@override String get openMarketSubtitle => 'Descobre e partilha sementes por perto';
@override String get yourInventory => 'O teu inventário';
@override String get yourInventorySubtitle => 'Gere as tuas sementes';
}
@ -618,8 +618,13 @@ class _Translations$market$pt extends Translations$market$en {
@override String get setupIntro => 'Partilhar com pessoas por perto é opcional. Basta indicar a tua zona aproximada — já estás ligado a servidores comunitários partilhados para que outras pessoas encontrem o que ofereces, sem nenhuma empresa no meio.';
@override String get areaLabel => 'A tua zona';
@override String get areaHelp => 'Mantém-se aproximado de propósito — a tua zona, nunca um ponto exato.';
@override String get areaSet => 'A tua zona está definida — aproximada, nunca o teu ponto exato';
@override String get areaNotSet => 'Zona por definir — usa a tua localização, ou adiciona um código em Avançado';
@override String get advanced => 'Avançado';
@override String get areaCodeLabel => 'Código de zona';
@override String get areaCodeHint => 'Um código curto como sp3e9 — não um nome de lugar';
@override String get serversLabel => 'Servidores da comunidade';
@override String get serversHelp => 'Escolhe um ou mais servidores partilhados. Qualquer pessoa os pode usar — ou adiciona abaixo o da tua comunidade.';
@override String get serversHelp => 'Um endereço por linha (wss://…). Deixa assim se não souberes.';
@override String get serversAdvanced => 'Adicionar outro servidor';
@override String get save => 'Guardar';
@override String get saved => 'Guardado';
@ -1044,8 +1049,8 @@ extension on TranslationsPt {
'common.type' => 'Tipo',
'common.comingSoon' => 'Em breve',
'home.tagline' => 'Partilha e cultiva sementes locais',
'home.openMarket' => 'Mercado aberto',
'home.openMarketSubtitle' => 'Descobre e troca',
'home.openMarket' => 'Mercado',
'home.openMarketSubtitle' => 'Descobre e partilha sementes por perto',
'home.yourInventory' => 'O teu inventário',
'home.yourInventorySubtitle' => 'Gere as tuas sementes',
'photo.camera' => 'Tirar uma foto',
@ -1342,8 +1347,13 @@ extension on TranslationsPt {
'market.setupIntro' => 'Partilhar com pessoas por perto é opcional. Basta indicar a tua zona aproximada — já estás ligado a servidores comunitários partilhados para que outras pessoas encontrem o que ofereces, sem nenhuma empresa no meio.',
'market.areaLabel' => 'A tua zona',
'market.areaHelp' => 'Mantém-se aproximado de propósito — a tua zona, nunca um ponto exato.',
'market.areaSet' => 'A tua zona está definida — aproximada, nunca o teu ponto exato',
'market.areaNotSet' => 'Zona por definir — usa a tua localização, ou adiciona um código em Avançado',
'market.advanced' => 'Avançado',
'market.areaCodeLabel' => 'Código de zona',
'market.areaCodeHint' => 'Um código curto como sp3e9 — não um nome de lugar',
'market.serversLabel' => 'Servidores da comunidade',
'market.serversHelp' => 'Escolhe um ou mais servidores partilhados. Qualquer pessoa os pode usar — ou adiciona abaixo o da tua comunidade.',
'market.serversHelp' => 'Um endereço por linha (wss://…). Deixa assim se não souberes.',
'market.serversAdvanced' => 'Adicionar outro servidor',
'market.save' => 'Guardar',
'market.saved' => 'Guardado',

View file

@ -20,16 +20,13 @@ class GeolocatorCoarseLocation implements CoarseLocationProvider {
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
}
// Give the user a way out of a permanent denial.
if (permission == LocationPermission.deniedForever) {
await Geolocator.openAppSettings();
return null;
}
if (permission == LocationPermission.denied) return null;
if (!await Geolocator.isLocationServiceEnabled()) {
await Geolocator.openLocationSettings();
// Denied (or turned off): return null quietly the UI shows an actionable
// message. We don't yank the user into system settings.
if (permission == LocationPermission.denied ||
permission == LocationPermission.deniedForever) {
return null;
}
if (!await Geolocator.isLocationServiceEnabled()) return null;
// A fresh coarse fix can take a while (or never come indoors); fall back
// to the last known position so the button stays responsive.

View file

@ -76,6 +76,7 @@ class OffersCubit extends Cubit<OffersState> {
/// (null in tests, where the transport is a fake with nothing to close).
final Future<void> Function()? _onDispose;
StreamSubscription<Offer>? _sub;
Timer? _searchTimeout;
/// Whether a live transport is available (relay configured and reachable).
bool get isOnline => _transport != null;
@ -88,6 +89,7 @@ class OffersCubit extends Cubit<OffersState> {
return;
}
await _sub?.cancel();
_searchTimeout?.cancel();
emit(OffersState(
areaGeohash: geohashPrefix,
searching: true,
@ -99,6 +101,14 @@ class OffersCubit extends Cubit<OffersState> {
onError: (Object e) =>
emit(state.copyWith(searching: false, error: () => '$e')),
);
// The discover stream stays open for live offers and never signals "done",
// so stop the spinner after a beat: no results show the empty state, not
// an endless "searching".
_searchTimeout = Timer(const Duration(seconds: 6), () {
if (!isClosed && state.searching) {
emit(state.copyWith(searching: false));
}
});
}
/// Publishes [offer]; returns the transport's verdict. No-op result when
@ -157,6 +167,7 @@ class OffersCubit extends Cubit<OffersState> {
@override
Future<void> close() async {
_searchTimeout?.cancel();
await _sub?.cancel();
await _onDispose?.call();
return super.close();

View file

@ -431,6 +431,7 @@ class _ConfigSheet extends StatefulWidget {
class _ConfigSheetState extends State<_ConfigSheet> {
final _area = TextEditingController();
final _servers = TextEditingController();
bool _loading = true;
bool _locationBusy = false;
String? _locationError;
@ -443,13 +444,13 @@ class _ConfigSheetState extends State<_ConfigSheet> {
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 {
// Relays are managed automatically (default community servers, dead ones
// skipped by the pool) the user only sets their area here.
await widget.settings.setAreaGeohash(_area.text);
await widget.settings.setRelayUrls(_servers.text.split('\n'));
if (mounted) Navigator.of(context).pop(true);
}
@ -477,12 +478,14 @@ class _ConfigSheetState extends State<_ConfigSheet> {
@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,
@ -491,40 +494,34 @@ class _ConfigSheetState extends State<_ConfigSheet> {
bottom: MediaQuery.of(context).viewInsets.bottom + 20,
),
child: _loading
? const SizedBox(height: 120, child: Center(child: CircularProgressIndicator()))
? 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,
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,
const SizedBox(height: 8),
Text(
t.market.setupIntro,
style: const TextStyle(
color: seedMuted, fontSize: 13, height: 1.4),
),
),
if (widget.location != null)
Align(
alignment: AlignmentDirectional.centerStart,
child: TextButton.icon(
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,
@ -533,26 +530,85 @@ class _ConfigSheetState extends State<_ConfigSheet> {
)
: 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: 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: 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,
),
),
],
),
),
if (_locationError != null)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
_locationError!,
style: const TextStyle(color: Color(0xFFB3261E), fontSize: 12),
),
const SizedBox(height: 20),
FilledButton(
key: const Key('market.save'),
onPressed: _save,
child: Text(t.market.save),
),
const SizedBox(height: 20),
FilledButton(
key: const Key('market.save'),
onPressed: _save,
child: Text(t.market.save),
),
],
],
),
),
),
);
}
}

View file

@ -107,8 +107,7 @@ void main() {
await tester.tap(find.byKey(const Key('market.useLocation')));
await tester.pumpAndSettle();
final area = tester.widget<TextField>(find.byKey(const Key('market.area')));
expect(area.controller!.text, hasLength(5));
expect(area.controller!.text, matches(RegExp(r'^[0-9a-z]+$')));
// The area status flips to "set" once a coarse location is captured.
expect(find.textContaining('Your area is set'), findsOneWidget);
});
}