fix(market): zone prompt before connection error; keep Save above the nav bar

- A new user's first step (set your area) works offline, so that empty state
  now wins over 'can't reach the servers'
- The sharing-setup sheet wraps in SafeArea(top: false) so the Save button
  isn't clipped by the system navigation bar on edge-to-edge devices
This commit is contained in:
vjrj 2026-07-22 13:03:25 +02:00
parent 49e9a45a7d
commit 3a87e112f9
2 changed files with 318 additions and 214 deletions

View file

@ -100,11 +100,11 @@ class _MarketScreenState extends State<MarketScreen> {
Future<void> _init() async { Future<void> _init() async {
// Only needed to flush the outbox; read here (while mounted) to avoid using // 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). // context across the awaits below. Null when there's no outbox (e.g. tests).
final repo = final repo = widget.outbox != null
widget.outbox != null ? context.read<VarietyRepository>() : null; ? context.read<VarietyRepository>()
: null;
setState(() => _loading = true); setState(() => _loading = true);
final cubit = final cubit = await createOffersCubit(widget.connection, repository: repo);
await createOffersCubit(widget.connection, repository: repo);
cubit.setBlockedAuthors(await widget.settings.blockedPubkeys()); cubit.setBlockedAuthors(await widget.settings.blockedPubkeys());
cubit.setHiddenOffers(await widget.settings.hiddenOfferKeys()); cubit.setHiddenOffers(await widget.settings.hiddenOfferKeys());
final area = await widget.settings.areaGeohash(); final area = await widget.settings.areaGeohash();
@ -208,12 +208,18 @@ class _MarketScreenState extends State<MarketScreen> {
await widget.outbox?.remove(ids); // published now, so unpark any duplicates await widget.outbox?.remove(ids); // published now, so unpark any duplicates
// count == 0 with lots to share means every relay refused say so plainly // count == 0 with lots to share means every relay refused say so plainly
// rather than "shared 0", which reads like success. // rather than "shared 0", which reads like success.
messenger.showSnackBar(SnackBar( messenger.showSnackBar(
content: Text(count == 0 ? t.market.shareFailed : t.market.sharedCount(n: count)), SnackBar(
)); content: Text(
count == 0 ? t.market.shareFailed : t.market.sharedCount(n: count),
),
),
);
final precision = await widget.settings.searchPrecision(); final precision = await widget.settings.searchPrecision();
if (!mounted) return; if (!mounted) return;
await cubit.discover(searchPrefix(area, precision)); // refresh incl. just-shared await cubit.discover(
searchPrefix(area, precision),
); // refresh incl. just-shared
} }
@override @override
@ -242,13 +248,13 @@ class _MarketScreenState extends State<MarketScreen> {
), ),
floatingActionButton: floatingActionButton:
cubit != null && (cubit.isOnline || widget.outbox != null) cubit != null && (cubit.isOnline || widget.outbox != null)
? FloatingActionButton.extended( ? FloatingActionButton.extended(
key: const Key('market.shareMine'), key: const Key('market.shareMine'),
onPressed: _shareMine, onPressed: _shareMine,
icon: const Icon(Icons.campaign_outlined), icon: const Icon(Icons.campaign_outlined),
label: Text(t.market.shareMine), label: Text(t.market.shareMine),
) )
: null, : null,
body: _loading || cubit == null body: _loading || cubit == null
? const Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())
: BlocProvider.value( : BlocProvider.value(
@ -300,6 +306,17 @@ class MarketBody extends StatelessWidget {
final t = context.t; final t = context.t;
return BlocBuilder<OffersCubit, OffersState>( return BlocBuilder<OffersCubit, OffersState>(
builder: (context, state) { builder: (context, state) {
// A new user's first step is setting a zone — which works offline — so
// that prompt wins over the connection error.
if (!hasArea) {
return _EmptyState(
icon: Icons.place_outlined,
title: t.market.setArea,
body: t.market.setAreaBody,
actionLabel: t.market.setUp,
onAction: onConfigure,
);
}
if (!context.read<OffersCubit>().isOnline) { if (!context.read<OffersCubit>().isOnline) {
// Default community servers exist, so "offline" means unreachable // Default community servers exist, so "offline" means unreachable
// offer a retry rather than "set up sharing". // offer a retry rather than "set up sharing".
@ -311,15 +328,6 @@ class MarketBody extends StatelessWidget {
onAction: onRetry, onAction: onRetry,
); );
} }
if (!hasArea) {
return _EmptyState(
icon: Icons.place_outlined,
title: t.market.setArea,
body: t.market.setAreaBody,
actionLabel: t.market.setUp,
onAction: onConfigure,
);
}
if (state.searching && state.offers.isEmpty) { if (state.searching && state.offers.isEmpty) {
return Center( return Center(
child: Column( child: Column(
@ -327,8 +335,10 @@ class MarketBody extends StatelessWidget {
children: [ children: [
const CircularProgressIndicator(), const CircularProgressIndicator(),
const SizedBox(height: 16), const SizedBox(height: 16),
Text(t.market.searching, Text(
style: const TextStyle(color: seedMuted)), t.market.searching,
style: const TextStyle(color: seedMuted),
),
], ],
), ),
); );
@ -372,16 +382,22 @@ class MarketBody extends StatelessWidget {
prefixIcon: const Icon(Icons.search, color: seedMuted), prefixIcon: const Icon(Icons.search, color: seedMuted),
// Offer to save the current search once it's worth alerting on // Offer to save the current search once it's worth alerting on
// (some text typed or a chip active), never for the bare list. // (some text typed or a chip active), never for the bare list.
suffixIcon: savedSearches != null && suffixIcon:
savedSearches != null &&
(state.query.trim().isNotEmpty || (state.query.trim().isNotEmpty ||
state.hasActiveFilter) state.hasActiveFilter)
? IconButton( ? IconButton(
key: const Key('market.saveSearch'), key: const Key('market.saveSearch'),
icon: const Icon(Icons.bookmark_add_outlined, icon: const Icon(
color: seedGreen), Icons.bookmark_add_outlined,
color: seedGreen,
),
tooltip: t.savedSearches.save, tooltip: t.savedSearches.save,
onPressed: () => onPressed: () => _saveCurrentSearch(
_saveCurrentSearch(context, savedSearches!, state), context,
savedSearches!,
state,
),
) )
: null, : null,
hintStyle: const TextStyle(color: seedMuted), hintStyle: const TextStyle(color: seedMuted),
@ -433,13 +449,17 @@ class MarketBody extends StatelessWidget {
physics: const AlwaysScrollableScrollPhysics(), physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
// A trailing spinner row while the next page loads. // A trailing spinner row while the next page loads.
itemCount: visible.length + (state.loadingMore ? 1 : 0), itemCount:
separatorBuilder: (_, _) => const SizedBox(height: 12), visible.length + (state.loadingMore ? 1 : 0),
separatorBuilder: (_, _) =>
const SizedBox(height: 12),
itemBuilder: (context, i) { itemBuilder: (context, i) {
if (i >= visible.length) { if (i >= visible.length) {
return const Padding( return const Padding(
padding: EdgeInsets.symmetric(vertical: 16), padding: EdgeInsets.symmetric(vertical: 16),
child: Center(child: CircularProgressIndicator()), child: Center(
child: CircularProgressIndicator(),
),
); );
} }
final o = visible[i]; final o = visible[i];
@ -576,7 +596,9 @@ class _OfferCard extends StatelessWidget {
if (mine) ...[ if (mine) ...[
Container( Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 3), horizontal: 8,
vertical: 3,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: seedGreen, color: seedGreen,
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
@ -600,8 +622,10 @@ class _OfferCard extends StatelessWidget {
children: [ children: [
const Icon(Icons.place_outlined, size: 15, color: seedMuted), const Icon(Icons.place_outlined, size: 15, color: seedMuted),
const SizedBox(width: 4), const SizedBox(width: 4),
Text(t.market.near, Text(
style: const TextStyle(color: seedMuted, fontSize: 13)), t.market.near,
style: const TextStyle(color: seedMuted, fontSize: 13),
),
if (offer.isOrganic) ...[ if (offer.isOrganic) ...[
const SizedBox(width: 10), const SizedBox(width: 10),
const Icon(Icons.eco, size: 15, color: seedGreen), const Icon(Icons.eco, size: 15, color: seedGreen),
@ -609,7 +633,8 @@ class _OfferCard extends StatelessWidget {
const Spacer(), const Spacer(),
if (offer.type == OfferType.sale && offer.priceAmount != null) if (offer.type == OfferType.sale && offer.priceAmount != null)
Text( Text(
'${offer.priceAmount} ${offer.priceCurrency ?? ''}'.trim(), '${offer.priceAmount} ${offer.priceCurrency ?? ''}'
.trim(),
style: const TextStyle( style: const TextStyle(
color: seedOnSurface, color: seedOnSurface,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
@ -908,194 +933,226 @@ class _ConfigSheetState extends State<_ConfigSheet> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final t = context.t; final t = context.t;
final hasArea = _area.text.trim().isNotEmpty; final hasArea = _area.text.trim().isNotEmpty;
return Padding( // SafeArea keeps the Save button above the system nav bar on edge-to-edge
padding: EdgeInsets.only( // devices; the viewInsets padding still lifts it above the keyboard.
left: 20, return SafeArea(
right: 20, top: false,
top: 20, child: Padding(
bottom: MediaQuery.of(context).viewInsets.bottom + 20, padding: EdgeInsets.only(
), left: 20,
child: _loading right: 20,
? const SizedBox( top: 20,
height: 120, child: Center(child: CircularProgressIndicator())) bottom: MediaQuery.of(context).viewInsets.bottom + 20,
: SingleChildScrollView( ),
child: Column( child: _loading
mainAxisSize: MainAxisSize.min, ? const SizedBox(
crossAxisAlignment: CrossAxisAlignment.stretch, height: 120,
children: [ child: Center(child: CircularProgressIndicator()),
Text( )
t.market.configTitle, : SingleChildScrollView(
style: const TextStyle( child: Column(
fontSize: 18, mainAxisSize: MainAxisSize.min,
fontWeight: FontWeight.w600, crossAxisAlignment: CrossAxisAlignment.stretch,
color: seedTitle, children: [
), Text(
), t.market.configTitle,
const SizedBox(height: 8), style: const TextStyle(
Text( fontSize: 18,
t.market.setupIntro, fontWeight: FontWeight.w600,
style: const TextStyle( color: seedTitle,
color: seedMuted, fontSize: 13, height: 1.4),
),
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,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.my_location, size: 18),
label: Text(t.market.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), const SizedBox(height: 8),
Row( Text(
children: [ t.market.setupIntro,
Icon( style: const TextStyle(
hasArea color: seedMuted,
? Icons.check_circle fontSize: 13,
: Icons.pending_outlined, height: 1.4,
size: 18,
color: hasArea ? seedGreen : seedMuted,
), ),
const SizedBox(width: 8), ),
Expanded( 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,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(Icons.my_location, size: 18),
label: Text(t.market.useLocation),
),
if (_locationError != null)
Padding(
padding: const EdgeInsets.only(top: 6),
child: Text( child: Text(
hasArea ? t.market.areaSet : t.market.areaNotSet, _locationError!,
style: TextStyle( style: const TextStyle(
color: hasArea ? seedOnSurface : seedMuted, color: Color(0xFFB3261E),
fontSize: 13, fontSize: 12,
), ),
), ),
), ),
], const SizedBox(height: 12),
), Row(
const SizedBox(height: 16),
Align(
alignment: AlignmentDirectional.centerStart,
child: Text(
t.market.rangeLabel,
style: const TextStyle(fontSize: 13, color: seedMuted),
),
),
const SizedBox(height: 8),
SegmentedButton<int>(
key: const Key('market.range'),
segments: [
ButtonSegment(value: 5, label: Text(t.market.rangeNear)),
ButtonSegment(value: 4, label: Text(t.market.rangeArea)),
ButtonSegment(value: 3, label: Text(t.market.rangeRegion)),
],
selected: {_precision},
showSelectedIcon: false,
onSelectionChanged: (s) =>
setState(() => _precision = s.first),
),
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: [ children: [
TextField( Icon(
key: const Key('market.area'), hasArea ? Icons.check_circle : Icons.pending_outlined,
controller: _area, size: 18,
onChanged: (_) => setState(() {}), color: hasArea ? seedGreen : seedMuted,
decoration: InputDecoration(
labelText: t.market.areaCodeLabel,
helperText: t.market.areaCodeHint,
helperMaxLines: 2,
),
), ),
const SizedBox(height: 14), const SizedBox(width: 8),
Align( Expanded(
alignment: AlignmentDirectional.centerStart,
child: Text( child: Text(
t.market.serversLabel, hasArea ? t.market.areaSet : t.market.areaNotSet,
style: const TextStyle( style: TextStyle(
fontSize: 13, color: seedMuted), color: hasArea ? seedOnSurface : seedMuted,
), fontSize: 13,
), ),
Padding(
padding: const EdgeInsetsDirectional.only(
top: 2, bottom: 4),
child: Text(
t.market.serversHelp,
style: const TextStyle(
fontSize: 12, color: seedMuted, height: 1.3),
),
),
for (final url in _knownRelays)
CheckboxListTile(
key: Key('market.server.$url'),
contentPadding: EdgeInsets.zero,
dense: true,
controlAffinity: ListTileControlAffinity.leading,
value: _selectedRelays.contains(url),
title: Text(_relayLabel(url)),
onChanged: (on) => setState(() {
if (on ?? false) {
_selectedRelays.add(url);
} else {
_selectedRelays.remove(url);
}
}),
),
Align(
alignment: AlignmentDirectional.centerStart,
child: TextButton.icon(
key: const Key('market.addServer'),
onPressed: _addServer,
icon: const Icon(Icons.add, size: 18),
label: Text(t.market.serversAdvanced),
),
),
ListTile(
key: const Key('market.blockedPeople'),
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.block, color: seedMuted),
title: Text(t.block.manageTitle),
trailing: const Icon(Icons.chevron_right),
onTap: () => showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
builder: (_) =>
BlockedPeopleSheet(settings: widget.settings),
), ),
), ),
], ],
), ),
), const SizedBox(height: 16),
const SizedBox(height: 20), Align(
FilledButton( alignment: AlignmentDirectional.centerStart,
key: const Key('market.save'), child: Text(
onPressed: _save, t.market.rangeLabel,
child: Text(t.market.save), style: const TextStyle(fontSize: 13, color: seedMuted),
), ),
], ),
const SizedBox(height: 8),
SegmentedButton<int>(
key: const Key('market.range'),
segments: [
ButtonSegment(
value: 5,
label: Text(t.market.rangeNear),
),
ButtonSegment(
value: 4,
label: Text(t.market.rangeArea),
),
ButtonSegment(
value: 3,
label: Text(t.market.rangeRegion),
),
],
selected: {_precision},
showSelectedIcon: false,
onSelectionChanged: (s) =>
setState(() => _precision = s.first),
),
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),
Align(
alignment: AlignmentDirectional.centerStart,
child: Text(
t.market.serversLabel,
style: const TextStyle(
fontSize: 13,
color: seedMuted,
),
),
),
Padding(
padding: const EdgeInsetsDirectional.only(
top: 2,
bottom: 4,
),
child: Text(
t.market.serversHelp,
style: const TextStyle(
fontSize: 12,
color: seedMuted,
height: 1.3,
),
),
),
for (final url in _knownRelays)
CheckboxListTile(
key: Key('market.server.$url'),
contentPadding: EdgeInsets.zero,
dense: true,
controlAffinity: ListTileControlAffinity.leading,
value: _selectedRelays.contains(url),
title: Text(_relayLabel(url)),
onChanged: (on) => setState(() {
if (on ?? false) {
_selectedRelays.add(url);
} else {
_selectedRelays.remove(url);
}
}),
),
Align(
alignment: AlignmentDirectional.centerStart,
child: TextButton.icon(
key: const Key('market.addServer'),
onPressed: _addServer,
icon: const Icon(Icons.add, size: 18),
label: Text(t.market.serversAdvanced),
),
),
ListTile(
key: const Key('market.blockedPeople'),
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.block, color: seedMuted),
title: Text(t.block.manageTitle),
trailing: const Icon(Icons.chevron_right),
onTap: () => showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
builder: (_) =>
BlockedPeopleSheet(settings: widget.settings),
),
),
],
),
),
const SizedBox(height: 20),
FilledButton(
key: const Key('market.save'),
onPressed: _save,
child: Text(t.market.save),
),
],
),
), ),
), ),
); );
} }
} }

View file

@ -84,8 +84,9 @@ void main() {
findsOneWidget); findsOneWidget);
}); });
testWidgets('MarketScreen with no relays configured degrades to offline', testWidgets(
(tester) async { 'a fresh install (no area, unreachable) asks for the area first, '
'not the connection', (tester) async {
final social = await SocialService.fromRootSeedHex('00' * 32); final social = await SocialService.fromRootSeedHex('00' * 32);
final settings = SocialSettings(InMemorySecretStore()); final settings = SocialSettings(InMemorySecretStore());
await settings.setRelayUrls(const []); // offline: don't hit the network await settings.setRelayUrls(const []); // offline: don't hit the network
@ -94,7 +95,23 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('Seeds near you'), findsOneWidget); // app bar title expect(find.text('Seeds near you'), findsOneWidget); // app bar title
expect(find.text('Retry'), findsOneWidget); // can't-reach state offers retry // Setting a zone works offline and is the first step the connection
// error must not bury it.
expect(find.text('Set your area'), findsOneWidget);
expect(find.text('Retry'), findsNothing);
});
testWidgets('with an area set, unreachable servers degrade to retry',
(tester) async {
final social = await SocialService.fromRootSeedHex('00' * 32);
final settings = SocialSettings(InMemorySecretStore());
await settings.setRelayUrls(const []); // offline: don't hit the network
await settings.setAreaGeohash('ezsn9');
await tester.pumpWidget(_wrapMarket(social, settings));
await tester.pumpAndSettle();
expect(find.text('Retry'), findsOneWidget);
}); });
testWidgets('the range selector persists the chosen search precision', testWidgets('the range selector persists the chosen search precision',
@ -148,6 +165,36 @@ void main() {
expect(tester.widget<CheckboxListTile>(find.byKey(firstKey)).value, isTrue); expect(tester.widget<CheckboxListTile>(find.byKey(firstKey)).value, isTrue);
}); });
testWidgets('the config sheet Save button stays above the system nav bar',
(tester) async {
// Edge-to-edge Android: a 50-logical-px gesture/nav bar at the bottom.
tester.view.padding = const FakeViewPadding(bottom: 150); // physical px
tester.view.viewPadding = const FakeViewPadding(bottom: 150);
addTearDown(tester.view.reset);
final social = await SocialService.fromRootSeedHex('00' * 32);
final settings = SocialSettings(InMemorySecretStore());
await settings.setRelayUrls(const []); // offline: don't hit the network
await tester.pumpWidget(_wrapMarket(social, settings));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('market.config')));
await tester.pumpAndSettle();
final save = find.byKey(const Key('market.save'));
await tester.ensureVisible(save);
await tester.pumpAndSettle();
final screenHeight = tester.view.physicalSize.height /
tester.view.devicePixelRatio; // 600 on the default test surface
const navBarLogical = 150 / 3.0; // FakeViewPadding is physical px
expect(
tester.getRect(save).bottom,
lessThanOrEqualTo(screenHeight - navBarLogical + 0.1),
reason: 'Save must not sit under the system navigation bar',
);
});
testWidgets('"use my location" fills the area with a coarse geohash', testWidgets('"use my location" fills the area with a coarse geohash',
(tester) async { (tester) async {
final social = await SocialService.fromRootSeedHex('00' * 32); final social = await SocialService.fromRootSeedHex('00' * 32);