From 13b6b4bfce471f133e3e6360ffab5b8154cbd021 Mon Sep 17 00:00:00 2001 From: vjrj Date: Wed, 22 Jul 2026 13:03:25 +0200 Subject: [PATCH] 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 --- apps/app_seeds/lib/ui/market_screen.dart | 479 ++++++++++-------- .../app_seeds/test/ui/market_screen_test.dart | 53 +- 2 files changed, 318 insertions(+), 214 deletions(-) diff --git a/apps/app_seeds/lib/ui/market_screen.dart b/apps/app_seeds/lib/ui/market_screen.dart index 5b6e0ed..b172ca2 100644 --- a/apps/app_seeds/lib/ui/market_screen.dart +++ b/apps/app_seeds/lib/ui/market_screen.dart @@ -100,11 +100,11 @@ class _MarketScreenState extends State { Future _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() : null; + final repo = widget.outbox != null + ? context.read() + : null; setState(() => _loading = true); - final cubit = - await createOffersCubit(widget.connection, repository: repo); + final cubit = await createOffersCubit(widget.connection, repository: repo); cubit.setBlockedAuthors(await widget.settings.blockedPubkeys()); cubit.setHiddenOffers(await widget.settings.hiddenOfferKeys()); final area = await widget.settings.areaGeohash(); @@ -208,12 +208,18 @@ class _MarketScreenState extends State { await widget.outbox?.remove(ids); // published now, so unpark any duplicates // count == 0 with lots to share means every relay refused — say so plainly // rather than "shared 0", which reads like success. - messenger.showSnackBar(SnackBar( - content: Text(count == 0 ? t.market.shareFailed : t.market.sharedCount(n: count)), - )); + messenger.showSnackBar( + SnackBar( + content: Text( + count == 0 ? t.market.shareFailed : t.market.sharedCount(n: count), + ), + ), + ); final precision = await widget.settings.searchPrecision(); if (!mounted) return; - await cubit.discover(searchPrefix(area, precision)); // refresh incl. just-shared + await cubit.discover( + searchPrefix(area, precision), + ); // refresh incl. just-shared } @override @@ -242,13 +248,13 @@ class _MarketScreenState extends State { ), floatingActionButton: cubit != null && (cubit.isOnline || widget.outbox != null) - ? FloatingActionButton.extended( - key: const Key('market.shareMine'), - onPressed: _shareMine, - icon: const Icon(Icons.campaign_outlined), - label: Text(t.market.shareMine), - ) - : null, + ? FloatingActionButton.extended( + key: const Key('market.shareMine'), + onPressed: _shareMine, + icon: const Icon(Icons.campaign_outlined), + label: Text(t.market.shareMine), + ) + : null, body: _loading || cubit == null ? const Center(child: CircularProgressIndicator()) : BlocProvider.value( @@ -300,6 +306,17 @@ class MarketBody extends StatelessWidget { final t = context.t; return BlocBuilder( 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().isOnline) { // Default community servers exist, so "offline" means unreachable — // offer a retry rather than "set up sharing". @@ -311,15 +328,6 @@ class MarketBody extends StatelessWidget { 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) { return Center( child: Column( @@ -327,8 +335,10 @@ class MarketBody extends StatelessWidget { children: [ const CircularProgressIndicator(), const SizedBox(height: 16), - Text(t.market.searching, - style: const TextStyle(color: seedMuted)), + Text( + t.market.searching, + style: const TextStyle(color: seedMuted), + ), ], ), ); @@ -372,16 +382,22 @@ class MarketBody extends StatelessWidget { prefixIcon: const Icon(Icons.search, color: seedMuted), // Offer to save the current search once it's worth alerting on // (some text typed or a chip active), never for the bare list. - suffixIcon: savedSearches != null && + suffixIcon: + savedSearches != null && (state.query.trim().isNotEmpty || state.hasActiveFilter) ? IconButton( key: const Key('market.saveSearch'), - icon: const Icon(Icons.bookmark_add_outlined, - color: seedGreen), + icon: const Icon( + Icons.bookmark_add_outlined, + color: seedGreen, + ), tooltip: t.savedSearches.save, - onPressed: () => - _saveCurrentSearch(context, savedSearches!, state), + onPressed: () => _saveCurrentSearch( + context, + savedSearches!, + state, + ), ) : null, hintStyle: const TextStyle(color: seedMuted), @@ -433,13 +449,17 @@ class MarketBody extends StatelessWidget { physics: const AlwaysScrollableScrollPhysics(), padding: const EdgeInsets.all(16), // A trailing spinner row while the next page loads. - itemCount: visible.length + (state.loadingMore ? 1 : 0), - separatorBuilder: (_, _) => const SizedBox(height: 12), + itemCount: + visible.length + (state.loadingMore ? 1 : 0), + separatorBuilder: (_, _) => + const SizedBox(height: 12), itemBuilder: (context, i) { if (i >= visible.length) { return const Padding( padding: EdgeInsets.symmetric(vertical: 16), - child: Center(child: CircularProgressIndicator()), + child: Center( + child: CircularProgressIndicator(), + ), ); } final o = visible[i]; @@ -576,7 +596,9 @@ class _OfferCard extends StatelessWidget { if (mine) ...[ Container( padding: const EdgeInsets.symmetric( - horizontal: 8, vertical: 3), + horizontal: 8, + vertical: 3, + ), decoration: BoxDecoration( color: seedGreen, borderRadius: BorderRadius.circular(20), @@ -600,8 +622,10 @@ class _OfferCard extends StatelessWidget { 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)), + Text( + t.market.near, + style: const TextStyle(color: seedMuted, fontSize: 13), + ), if (offer.isOrganic) ...[ const SizedBox(width: 10), const Icon(Icons.eco, size: 15, color: seedGreen), @@ -609,7 +633,8 @@ class _OfferCard extends StatelessWidget { const Spacer(), if (offer.type == OfferType.sale && offer.priceAmount != null) Text( - '${offer.priceAmount} ${offer.priceCurrency ?? ''}'.trim(), + '${offer.priceAmount} ${offer.priceCurrency ?? ''}' + .trim(), style: const TextStyle( color: seedOnSurface, fontWeight: FontWeight.w600, @@ -908,194 +933,226 @@ class _ConfigSheetState extends State<_ConfigSheet> { Widget build(BuildContext context) { final t = context.t; final hasArea = _area.text.trim().isNotEmpty; - 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: 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), + // SafeArea keeps the Save button above the system nav bar on edge-to-edge + // devices; the viewInsets padding still lifts it above the keyboard. + return SafeArea( + top: false, + child: 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: 12), - Row( - children: [ - Icon( - hasArea - ? Icons.check_circle - : Icons.pending_outlined, - size: 18, - color: hasArea ? seedGreen : seedMuted, + const SizedBox(height: 8), + Text( + t.market.setupIntro, + style: const TextStyle( + color: seedMuted, + fontSize: 13, + height: 1.4, ), - 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( - hasArea ? t.market.areaSet : t.market.areaNotSet, - style: TextStyle( - color: hasArea ? seedOnSurface : seedMuted, - fontSize: 13, + _locationError!, + style: const TextStyle( + color: Color(0xFFB3261E), + fontSize: 12, ), ), ), - ], - ), - 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( - 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), - ), + const SizedBox(height: 12), + Row( children: [ - TextField( - key: const Key('market.area'), - controller: _area, - onChanged: (_) => setState(() {}), - decoration: InputDecoration( - labelText: t.market.areaCodeLabel, - helperText: t.market.areaCodeHint, - helperMaxLines: 2, - ), + Icon( + hasArea ? Icons.check_circle : Icons.pending_outlined, + size: 18, + color: hasArea ? seedGreen : seedMuted, ), - const SizedBox(height: 14), - Align( - alignment: AlignmentDirectional.centerStart, + const SizedBox(width: 8), + Expanded( 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( - context: context, - isScrollControlled: true, - builder: (_) => - BlockedPeopleSheet(settings: widget.settings), + hasArea ? t.market.areaSet : t.market.areaNotSet, + style: TextStyle( + color: hasArea ? seedOnSurface : seedMuted, + fontSize: 13, + ), ), ), ], ), - ), - const SizedBox(height: 20), - FilledButton( - key: const Key('market.save'), - onPressed: _save, - child: Text(t.market.save), - ), - ], + 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( + 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( + 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), + ), + ], + ), ), - ), + ), ); } } diff --git a/apps/app_seeds/test/ui/market_screen_test.dart b/apps/app_seeds/test/ui/market_screen_test.dart index b063201..5f99e18 100644 --- a/apps/app_seeds/test/ui/market_screen_test.dart +++ b/apps/app_seeds/test/ui/market_screen_test.dart @@ -84,8 +84,9 @@ void main() { findsOneWidget); }); - testWidgets('MarketScreen with no relays configured degrades to offline', - (tester) async { + testWidgets( + 'a fresh install (no area, unreachable) asks for the area first, ' + 'not the connection', (tester) async { final social = await SocialService.fromRootSeedHex('00' * 32); final settings = SocialSettings(InMemorySecretStore()); await settings.setRelayUrls(const []); // offline: don't hit the network @@ -94,7 +95,23 @@ void main() { await tester.pumpAndSettle(); 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', @@ -148,6 +165,36 @@ void main() { expect(tester.widget(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', (tester) async { final social = await SocialService.fromRootSeedHex('00' * 32);