feat(market): saved searches with in-app alerts (Wallapop-style)

Save the current market search (query + facets) and get notified when a
matching offer appears in your zone while the app is open, with a catch-up
scan on start. Adds a save affordance in the search bar, a saved-searches
list (apply/delete) with a per-search new-match badge, an AppBar entry
badged with the unseen total, and notification-tap routing to the list.
Alerts evaluate against the user's current area, dedup via seen keys, and
skip own/blocked/hidden/non-active offers. en+es strings.
This commit is contained in:
vjrj 2026-07-17 13:10:52 +02:00
parent c0cd299408
commit 93028c4933
9 changed files with 549 additions and 4 deletions

View file

@ -8,6 +8,7 @@ import '../i18n/strings.g.dart';
import '../services/coarse_location.dart';
import '../services/discovery_area.dart';
import '../services/offer_outbox.dart';
import '../services/saved_searches_store.dart';
import '../services/social_connection.dart';
import '../services/social_service.dart';
import '../services/social_settings.dart';
@ -18,6 +19,7 @@ import 'edge_fade.dart';
import 'filter_chips.dart';
import 'market_gate.dart';
import 'market_widgets.dart';
import 'saved_searches_screen.dart';
import 'theme.dart';
/// The market (redesign screens 04/05): discover seeds shared near you. Opens
@ -31,12 +33,22 @@ class MarketScreen extends StatefulWidget {
this.location,
this.outbox,
this.onboarding,
this.savedSearches,
this.initialSearch,
super.key,
});
final SocialService social;
final SocialSettings settings;
/// Optional store of the user's saved searches (with alerts). Null in tests /
/// inventory-only the save affordance and the saved-searches action hide.
final SavedSearchesStore? savedSearches;
/// When set (arriving from the saved-searches list), the market opens with
/// this search's query and facets already applied.
final SavedSearch? initialSearch;
/// When set, joining the market is gated on a one-time acceptance of the
/// community rules (store UGC policies require it before content is
/// created). Null in tests no gate.
@ -100,6 +112,9 @@ class _MarketScreenState extends State<MarketScreen> {
await cubit.close();
return;
}
// Arriving from the saved-searches list: pre-apply its query and facets.
final initial = widget.initialSearch;
if (initial != null) cubit.applySavedSearch(initial);
final previous = _cubit;
setState(() {
_cubit = cubit;
@ -213,6 +228,8 @@ class _MarketScreenState extends State<MarketScreen> {
appBar: AppBar(
title: Text(t.market.title),
actions: [
if (widget.savedSearches != null)
SavedSearchesAction(store: widget.savedSearches!),
IconButton(
key: const Key('market.config'),
icon: const Icon(Icons.tune),
@ -240,6 +257,7 @@ class _MarketScreenState extends State<MarketScreen> {
hasArea: _hasArea,
selfPubkey: widget.social.publicKeyHex,
onOfferClosed: _reloadBlocked,
savedSearches: widget.savedSearches,
),
),
);
@ -253,11 +271,15 @@ class MarketBody extends StatelessWidget {
this.hasArea = true,
this.selfPubkey,
this.onOfferClosed,
this.savedSearches,
super.key,
});
final VoidCallback onConfigure;
/// Store for the "save this search" affordance; null the button hides.
final SavedSearchesStore? savedSearches;
/// Re-opens the connection (for the "can't reach servers" retry).
final VoidCallback? onRetry;
@ -346,6 +368,20 @@ class MarketBody extends StatelessWidget {
decoration: InputDecoration(
hintText: t.market.searchHint,
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 &&
(state.query.trim().isNotEmpty ||
state.hasActiveFilter)
? IconButton(
key: const Key('market.saveSearch'),
icon: const Icon(Icons.bookmark_add_outlined,
color: seedGreen),
tooltip: t.savedSearches.save,
onPressed: () =>
_saveCurrentSearch(context, savedSearches!, state),
)
: null,
hintStyle: const TextStyle(color: seedMuted),
filled: true,
fillColor: Colors.white,
@ -405,6 +441,63 @@ class MarketBody extends StatelessWidget {
}
}
/// Prompts for a name, then saves the current query + facets as a search the
/// user will be alerted about. Offers already visible are seeded as "seen" so
/// saving never immediately alerts about listings the user is looking at.
Future<void> _saveCurrentSearch(
BuildContext context,
SavedSearchesStore store,
OffersState state,
) async {
final t = context.t;
final messenger = ScaffoldMessenger.of(context);
final defaultLabel = state.query.trim();
final controller = TextEditingController(text: defaultLabel);
final label = await showDialog<String>(
context: context,
builder: (context) => AlertDialog(
title: Text(t.savedSearches.save),
content: TextField(
controller: controller,
autofocus: true,
decoration: InputDecoration(
labelText: t.savedSearches.nameLabel,
hintText: t.savedSearches.namePlaceholder,
),
onSubmitted: (v) => Navigator.of(context).pop(v),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(t.common.cancel),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(controller.text),
child: Text(t.common.save),
),
],
),
);
if (label == null) return; // cancelled
final trimmed = label.trim();
final search = SavedSearch(
id: IdGen().newId(),
label: trimmed.isEmpty ? t.savedSearches.title : trimmed,
query: state.query.trim(),
types: state.typeFilter,
categories: state.categoryFilter,
organicOnly: state.organicOnly,
createdAt: DateTime.now().millisecondsSinceEpoch,
);
await store.save(
search,
seedSeenKeys: [
for (final o in state.visibleOffers) SavedSearchesStore.keyOf(o),
],
);
messenger.showSnackBar(SnackBar(content: Text(t.savedSearches.saved)));
}
/// 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 {