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:
parent
c0cd299408
commit
93028c4933
9 changed files with 549 additions and 4 deletions
|
|
@ -18,6 +18,7 @@ import 'services/onboarding_store.dart';
|
||||||
import 'services/profile_cache.dart';
|
import 'services/profile_cache.dart';
|
||||||
import 'services/profile_store.dart';
|
import 'services/profile_store.dart';
|
||||||
import 'services/saved_offers_store.dart';
|
import 'services/saved_offers_store.dart';
|
||||||
|
import 'services/saved_searches_store.dart';
|
||||||
import 'services/social_account_store.dart';
|
import 'services/social_account_store.dart';
|
||||||
import 'services/social_connection.dart';
|
import 'services/social_connection.dart';
|
||||||
import 'services/social_service.dart';
|
import 'services/social_service.dart';
|
||||||
|
|
@ -36,6 +37,7 @@ import 'ui/inventory_list_screen.dart';
|
||||||
import 'ui/legal_screen.dart';
|
import 'ui/legal_screen.dart';
|
||||||
import 'ui/plantares_screen.dart';
|
import 'ui/plantares_screen.dart';
|
||||||
import 'ui/sales_screen.dart';
|
import 'ui/sales_screen.dart';
|
||||||
|
import 'ui/saved_searches_screen.dart';
|
||||||
import 'ui/market_offer_detail_screen.dart';
|
import 'ui/market_offer_detail_screen.dart';
|
||||||
import 'ui/market_screen.dart';
|
import 'ui/market_screen.dart';
|
||||||
import 'ui/offline_banner.dart';
|
import 'ui/offline_banner.dart';
|
||||||
|
|
@ -62,6 +64,7 @@ class TaneApp extends StatelessWidget {
|
||||||
this.profileStore,
|
this.profileStore,
|
||||||
this.profileCache,
|
this.profileCache,
|
||||||
this.savedOffers,
|
this.savedOffers,
|
||||||
|
this.savedSearches,
|
||||||
this.socialAccounts,
|
this.socialAccounts,
|
||||||
this.inbox,
|
this.inbox,
|
||||||
this.notifications,
|
this.notifications,
|
||||||
|
|
@ -81,6 +84,7 @@ class TaneApp extends StatelessWidget {
|
||||||
profileStore,
|
profileStore,
|
||||||
profileCache,
|
profileCache,
|
||||||
savedOffers,
|
savedOffers,
|
||||||
|
savedSearches,
|
||||||
socialAccounts,
|
socialAccounts,
|
||||||
inbox,
|
inbox,
|
||||||
) {
|
) {
|
||||||
|
|
@ -88,6 +92,8 @@ class TaneApp extends StatelessWidget {
|
||||||
// the router only exists now; taps only happen while the app is foreground,
|
// the router only exists now; taps only happen while the app is foreground,
|
||||||
// so a live router reference is enough.
|
// so a live router reference is enough.
|
||||||
notifications?.onTapChat = (pubkey) => _router.push('/chat/$pubkey');
|
notifications?.onTapChat = (pubkey) => _router.push('/chat/$pubkey');
|
||||||
|
// A tapped saved-search alert opens the saved-searches list.
|
||||||
|
notifications?.onTapSearch = (_) => _router.push('/saved-searches');
|
||||||
}
|
}
|
||||||
|
|
||||||
final VarietyRepository repository;
|
final VarietyRepository repository;
|
||||||
|
|
@ -120,6 +126,9 @@ class TaneApp extends StatelessWidget {
|
||||||
/// Optional store of the user's saved ("favorite") market offers.
|
/// Optional store of the user's saved ("favorite") market offers.
|
||||||
final SavedOffersStore? savedOffers;
|
final SavedOffersStore? savedOffers;
|
||||||
|
|
||||||
|
/// Optional store of the user's saved market searches (with alerts).
|
||||||
|
final SavedSearchesStore? savedSearches;
|
||||||
|
|
||||||
/// Optional store of the active social identity, for the profile switcher.
|
/// Optional store of the active social identity, for the profile switcher.
|
||||||
final SocialAccountStore? socialAccounts;
|
final SocialAccountStore? socialAccounts;
|
||||||
|
|
||||||
|
|
@ -149,6 +158,7 @@ class TaneApp extends StatelessWidget {
|
||||||
ProfileStore? profileStore,
|
ProfileStore? profileStore,
|
||||||
ProfileCache? profileCache,
|
ProfileCache? profileCache,
|
||||||
SavedOffersStore? savedOffers,
|
SavedOffersStore? savedOffers,
|
||||||
|
SavedSearchesStore? savedSearches,
|
||||||
SocialAccountStore? socialAccounts,
|
SocialAccountStore? socialAccounts,
|
||||||
InboxService? inbox,
|
InboxService? inbox,
|
||||||
) {
|
) {
|
||||||
|
|
@ -170,6 +180,7 @@ class TaneApp extends StatelessWidget {
|
||||||
location: location,
|
location: location,
|
||||||
outbox: outbox,
|
outbox: outbox,
|
||||||
onboarding: onboarding,
|
onboarding: onboarding,
|
||||||
|
savedSearches: savedSearches,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (social != null && connection != null)
|
if (social != null && connection != null)
|
||||||
|
|
@ -196,6 +207,12 @@ class TaneApp extends StatelessWidget {
|
||||||
connection: connection,
|
connection: connection,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (social != null && savedSearches != null)
|
||||||
|
GoRoute(
|
||||||
|
path: '/saved-searches',
|
||||||
|
builder: (context, state) =>
|
||||||
|
SavedSearchesScreen(store: savedSearches),
|
||||||
|
),
|
||||||
if (messageStore != null)
|
if (messageStore != null)
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/messages',
|
path: '/messages',
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,8 @@ import 'services/plantare_service.dart';
|
||||||
import 'services/profile_cache.dart';
|
import 'services/profile_cache.dart';
|
||||||
import 'services/profile_store.dart';
|
import 'services/profile_store.dart';
|
||||||
import 'services/saved_offers_store.dart';
|
import 'services/saved_offers_store.dart';
|
||||||
|
import 'services/saved_search_alert_service.dart';
|
||||||
|
import 'services/saved_searches_store.dart';
|
||||||
import 'services/social_account_store.dart';
|
import 'services/social_account_store.dart';
|
||||||
import 'services/social_connection.dart';
|
import 'services/social_connection.dart';
|
||||||
import 'services/social_service.dart';
|
import 'services/social_service.dart';
|
||||||
|
|
@ -70,12 +72,16 @@ class _BootstrapState extends State<Bootstrap> {
|
||||||
getIt.isRegistered<SyncService>() ? getIt<SyncService>() : null;
|
getIt.isRegistered<SyncService>() ? getIt<SyncService>() : null;
|
||||||
final plantares =
|
final plantares =
|
||||||
getIt.isRegistered<PlantareService>() ? getIt<PlantareService>() : null;
|
getIt.isRegistered<PlantareService>() ? getIt<PlantareService>() : null;
|
||||||
// Subscribe the inbox + sync + plantaré listeners BEFORE the shared
|
final savedSearchAlerts = getIt.isRegistered<SavedSearchAlertService>()
|
||||||
// connection starts connecting, so the first session is caught; then bring
|
? getIt<SavedSearchAlertService>()
|
||||||
// the connection up.
|
: null;
|
||||||
|
// Subscribe the inbox + sync + plantaré + saved-search listeners BEFORE the
|
||||||
|
// shared connection starts connecting, so the first session is caught; then
|
||||||
|
// bring the connection up.
|
||||||
inbox?.start();
|
inbox?.start();
|
||||||
sync?.start();
|
sync?.start();
|
||||||
plantares?.start();
|
plantares?.start();
|
||||||
|
savedSearchAlerts?.start();
|
||||||
connection?.start();
|
connection?.start();
|
||||||
|
|
||||||
return TaneApp(
|
return TaneApp(
|
||||||
|
|
@ -92,6 +98,7 @@ class _BootstrapState extends State<Bootstrap> {
|
||||||
profileStore: getIt<ProfileStore>(),
|
profileStore: getIt<ProfileStore>(),
|
||||||
profileCache: getIt<ProfileCache>(),
|
profileCache: getIt<ProfileCache>(),
|
||||||
savedOffers: getIt<SavedOffersStore>(),
|
savedOffers: getIt<SavedOffersStore>(),
|
||||||
|
savedSearches: getIt<SavedSearchesStore>(),
|
||||||
socialAccounts: getIt<SocialAccountStore>(),
|
socialAccounts: getIt<SocialAccountStore>(),
|
||||||
inbox: inbox,
|
inbox: inbox,
|
||||||
notifications: notifications,
|
notifications: notifications,
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,8 @@ import '../services/plantare_service.dart';
|
||||||
import '../services/profile_cache.dart';
|
import '../services/profile_cache.dart';
|
||||||
import '../services/profile_store.dart';
|
import '../services/profile_store.dart';
|
||||||
import '../services/saved_offers_store.dart';
|
import '../services/saved_offers_store.dart';
|
||||||
|
import '../services/saved_search_alert_service.dart';
|
||||||
|
import '../services/saved_searches_store.dart';
|
||||||
import '../services/social_account_store.dart';
|
import '../services/social_account_store.dart';
|
||||||
import '../services/social_connection.dart';
|
import '../services/social_connection.dart';
|
||||||
import '../services/social_service.dart';
|
import '../services/social_service.dart';
|
||||||
|
|
@ -189,6 +191,9 @@ Future<void> configureDependencies() async {
|
||||||
..registerSingleton<SavedOffersStore>(
|
..registerSingleton<SavedOffersStore>(
|
||||||
SavedOffersStore(secretStore, accountScope: scope),
|
SavedOffersStore(secretStore, accountScope: scope),
|
||||||
)
|
)
|
||||||
|
..registerSingleton<SavedSearchesStore>(
|
||||||
|
SavedSearchesStore(secretStore, accountScope: scope),
|
||||||
|
)
|
||||||
..registerSingleton<ExportImportService>(
|
..registerSingleton<ExportImportService>(
|
||||||
ExportImportService(
|
ExportImportService(
|
||||||
repository: varietyRepository,
|
repository: varietyRepository,
|
||||||
|
|
@ -273,6 +278,18 @@ Future<void> configureDependencies() async {
|
||||||
selfSecretKey: socialService.identity.privateKeyHex,
|
selfSecretKey: socialService.identity.privateKeyHex,
|
||||||
connection: connection,
|
connection: connection,
|
||||||
),
|
),
|
||||||
|
)
|
||||||
|
// Watches the market for offers matching the user's saved searches and
|
||||||
|
// fires an OS alert on a fresh match. Started in `Bootstrap`.
|
||||||
|
..registerSingleton<SavedSearchAlertService>(
|
||||||
|
SavedSearchAlertService(
|
||||||
|
connection: connection,
|
||||||
|
selfPubkey: socialService.publicKeyHex,
|
||||||
|
store: getIt<SavedSearchesStore>(),
|
||||||
|
settings: getIt<SocialSettings>(),
|
||||||
|
notifications: getIt<NotificationService>(),
|
||||||
|
alertTitle: (s) => t.savedSearches.alert(label: s.label),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -309,6 +326,10 @@ Future<void> switchSocialAccount(int account) async {
|
||||||
await getIt<PlantareService>().stop();
|
await getIt<PlantareService>().stop();
|
||||||
await getIt.unregister<PlantareService>();
|
await getIt.unregister<PlantareService>();
|
||||||
}
|
}
|
||||||
|
if (getIt.isRegistered<SavedSearchAlertService>()) {
|
||||||
|
await getIt<SavedSearchAlertService>().stop();
|
||||||
|
await getIt.unregister<SavedSearchAlertService>();
|
||||||
|
}
|
||||||
if (getIt.isRegistered<SocialConnection>()) {
|
if (getIt.isRegistered<SocialConnection>()) {
|
||||||
await getIt<SocialConnection>().dispose();
|
await getIt<SocialConnection>().dispose();
|
||||||
await getIt.unregister<SocialConnection>();
|
await getIt.unregister<SocialConnection>();
|
||||||
|
|
@ -324,6 +345,8 @@ Future<void> switchSocialAccount(int account) async {
|
||||||
await getIt.unregister<ProfileCache>();
|
await getIt.unregister<ProfileCache>();
|
||||||
await getIt<SavedOffersStore>().close();
|
await getIt<SavedOffersStore>().close();
|
||||||
await getIt.unregister<SavedOffersStore>();
|
await getIt.unregister<SavedOffersStore>();
|
||||||
|
await getIt<SavedSearchesStore>().close();
|
||||||
|
await getIt.unregister<SavedSearchesStore>();
|
||||||
|
|
||||||
// Re-register the per-identity stores under the new scope, then the identity.
|
// Re-register the per-identity stores under the new scope, then the identity.
|
||||||
getIt
|
getIt
|
||||||
|
|
@ -339,6 +362,9 @@ Future<void> switchSocialAccount(int account) async {
|
||||||
..registerSingleton<SavedOffersStore>(
|
..registerSingleton<SavedOffersStore>(
|
||||||
SavedOffersStore(secretStore, accountScope: scope),
|
SavedOffersStore(secretStore, accountScope: scope),
|
||||||
)
|
)
|
||||||
|
..registerSingleton<SavedSearchesStore>(
|
||||||
|
SavedSearchesStore(secretStore, accountScope: scope),
|
||||||
|
)
|
||||||
..registerSingleton<UnreadService>(
|
..registerSingleton<UnreadService>(
|
||||||
UnreadService(getIt<MessageStore>(), secretStore),
|
UnreadService(getIt<MessageStore>(), secretStore),
|
||||||
);
|
);
|
||||||
|
|
@ -373,16 +399,26 @@ Future<void> switchSocialAccount(int account) async {
|
||||||
selfSecretKey: social.identity.privateKeyHex,
|
selfSecretKey: social.identity.privateKeyHex,
|
||||||
connection: connection,
|
connection: connection,
|
||||||
);
|
);
|
||||||
|
final savedSearchAlerts = SavedSearchAlertService(
|
||||||
|
connection: connection,
|
||||||
|
selfPubkey: social.publicKeyHex,
|
||||||
|
store: getIt<SavedSearchesStore>(),
|
||||||
|
settings: getIt<SocialSettings>(),
|
||||||
|
notifications: getIt<NotificationService>(),
|
||||||
|
alertTitle: (s) => t.savedSearches.alert(label: s.label),
|
||||||
|
);
|
||||||
getIt
|
getIt
|
||||||
..registerSingleton<SocialService>(social)
|
..registerSingleton<SocialService>(social)
|
||||||
..registerSingleton<SocialConnection>(connection)
|
..registerSingleton<SocialConnection>(connection)
|
||||||
..registerSingleton<InboxService>(inbox)
|
..registerSingleton<InboxService>(inbox)
|
||||||
..registerSingleton<SyncService>(sync)
|
..registerSingleton<SyncService>(sync)
|
||||||
..registerSingleton<PlantareService>(plantares);
|
..registerSingleton<PlantareService>(plantares)
|
||||||
|
..registerSingleton<SavedSearchAlertService>(savedSearchAlerts);
|
||||||
// Subscribe the listeners BEFORE the connection starts connecting.
|
// Subscribe the listeners BEFORE the connection starts connecting.
|
||||||
inbox.start();
|
inbox.start();
|
||||||
sync.start();
|
sync.start();
|
||||||
plantares.start();
|
plantares.start();
|
||||||
|
savedSearchAlerts.start();
|
||||||
connection.start();
|
connection.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,19 @@
|
||||||
"remove": "Remove from favorites",
|
"remove": "Remove from favorites",
|
||||||
"unavailable": "No longer available"
|
"unavailable": "No longer available"
|
||||||
},
|
},
|
||||||
|
"savedSearches": {
|
||||||
|
"title": "Saved searches",
|
||||||
|
"empty": "No saved searches yet. Save a search from the market and we'll tell you when something like it appears in your zone.",
|
||||||
|
"save": "Save this search",
|
||||||
|
"nameLabel": "Name this search",
|
||||||
|
"namePlaceholder": "e.g. Tomatoes near me",
|
||||||
|
"saved": "Search saved — we'll alert you about new matches nearby",
|
||||||
|
"openTooltip": "Saved searches",
|
||||||
|
"delete": "Delete",
|
||||||
|
"deleteConfirm": "Delete this saved search?",
|
||||||
|
"newMatchesBadge": "{n} new",
|
||||||
|
"alert": "New seeds near you: {label}"
|
||||||
|
},
|
||||||
"seedSaving": {
|
"seedSaving": {
|
||||||
"title": "Saving its seed",
|
"title": "Saving its seed",
|
||||||
"subtitle": "What it takes to keep the variety true",
|
"subtitle": "What it takes to keep the variety true",
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,19 @@
|
||||||
"remove": "Quitar de favoritos",
|
"remove": "Quitar de favoritos",
|
||||||
"unavailable": "Ya no está disponible"
|
"unavailable": "Ya no está disponible"
|
||||||
},
|
},
|
||||||
|
"savedSearches": {
|
||||||
|
"title": "Búsquedas guardadas",
|
||||||
|
"empty": "Aún no tienes búsquedas guardadas. Guarda una búsqueda desde el mercado y te avisaremos cuando aparezca algo así en tu zona.",
|
||||||
|
"save": "Guardar esta búsqueda",
|
||||||
|
"nameLabel": "Nombra esta búsqueda",
|
||||||
|
"namePlaceholder": "p. ej. Tomates cerca",
|
||||||
|
"saved": "Búsqueda guardada — te avisaremos de lo nuevo que encaje cerca",
|
||||||
|
"openTooltip": "Búsquedas guardadas",
|
||||||
|
"delete": "Eliminar",
|
||||||
|
"deleteConfirm": "¿Eliminar esta búsqueda guardada?",
|
||||||
|
"newMatchesBadge": "{n} nuevas",
|
||||||
|
"alert": "Semillas nuevas cerca: {label}"
|
||||||
|
},
|
||||||
"seedSaving": {
|
"seedSaving": {
|
||||||
"title": "Conservar su semilla",
|
"title": "Conservar su semilla",
|
||||||
"subtitle": "Lo que hace falta para mantener la variedad fiel",
|
"subtitle": "Lo que hace falta para mantener la variedad fiel",
|
||||||
|
|
|
||||||
|
|
@ -267,6 +267,16 @@ class OffersCubit extends Cubit<OffersState> {
|
||||||
void setHiddenOffers(Set<String> offerKeys) =>
|
void setHiddenOffers(Set<String> offerKeys) =>
|
||||||
emit(state.copyWith(hiddenOfferKeys: offerKeys));
|
emit(state.copyWith(hiddenOfferKeys: offerKeys));
|
||||||
|
|
||||||
|
/// Applies a saved search's query and facets to the current discoveries.
|
||||||
|
/// Purely local (does not re-hit the transport) — the area is unchanged, so
|
||||||
|
/// the same discovered offers are simply re-narrowed by the saved filters.
|
||||||
|
void applySavedSearch(SavedSearch search) => emit(state.copyWith(
|
||||||
|
query: search.query,
|
||||||
|
typeFilter: search.types,
|
||||||
|
categoryFilter: search.categories,
|
||||||
|
organicOnly: search.organicOnly,
|
||||||
|
));
|
||||||
|
|
||||||
/// Clears every chip filter (leaves the text search untouched).
|
/// Clears every chip filter (leaves the text search untouched).
|
||||||
void clearFilters() => emit(state.copyWith(
|
void clearFilters() => emit(state.copyWith(
|
||||||
typeFilter: const {},
|
typeFilter: const {},
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import '../i18n/strings.g.dart';
|
||||||
import '../services/coarse_location.dart';
|
import '../services/coarse_location.dart';
|
||||||
import '../services/discovery_area.dart';
|
import '../services/discovery_area.dart';
|
||||||
import '../services/offer_outbox.dart';
|
import '../services/offer_outbox.dart';
|
||||||
|
import '../services/saved_searches_store.dart';
|
||||||
import '../services/social_connection.dart';
|
import '../services/social_connection.dart';
|
||||||
import '../services/social_service.dart';
|
import '../services/social_service.dart';
|
||||||
import '../services/social_settings.dart';
|
import '../services/social_settings.dart';
|
||||||
|
|
@ -18,6 +19,7 @@ import 'edge_fade.dart';
|
||||||
import 'filter_chips.dart';
|
import 'filter_chips.dart';
|
||||||
import 'market_gate.dart';
|
import 'market_gate.dart';
|
||||||
import 'market_widgets.dart';
|
import 'market_widgets.dart';
|
||||||
|
import 'saved_searches_screen.dart';
|
||||||
import 'theme.dart';
|
import 'theme.dart';
|
||||||
|
|
||||||
/// The market (redesign screens 04/05): discover seeds shared near you. Opens
|
/// The market (redesign screens 04/05): discover seeds shared near you. Opens
|
||||||
|
|
@ -31,12 +33,22 @@ class MarketScreen extends StatefulWidget {
|
||||||
this.location,
|
this.location,
|
||||||
this.outbox,
|
this.outbox,
|
||||||
this.onboarding,
|
this.onboarding,
|
||||||
|
this.savedSearches,
|
||||||
|
this.initialSearch,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
final SocialService social;
|
final SocialService social;
|
||||||
final SocialSettings settings;
|
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
|
/// When set, joining the market is gated on a one-time acceptance of the
|
||||||
/// community rules (store UGC policies require it before content is
|
/// community rules (store UGC policies require it before content is
|
||||||
/// created). Null in tests → no gate.
|
/// created). Null in tests → no gate.
|
||||||
|
|
@ -100,6 +112,9 @@ class _MarketScreenState extends State<MarketScreen> {
|
||||||
await cubit.close();
|
await cubit.close();
|
||||||
return;
|
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;
|
final previous = _cubit;
|
||||||
setState(() {
|
setState(() {
|
||||||
_cubit = cubit;
|
_cubit = cubit;
|
||||||
|
|
@ -213,6 +228,8 @@ class _MarketScreenState extends State<MarketScreen> {
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(t.market.title),
|
title: Text(t.market.title),
|
||||||
actions: [
|
actions: [
|
||||||
|
if (widget.savedSearches != null)
|
||||||
|
SavedSearchesAction(store: widget.savedSearches!),
|
||||||
IconButton(
|
IconButton(
|
||||||
key: const Key('market.config'),
|
key: const Key('market.config'),
|
||||||
icon: const Icon(Icons.tune),
|
icon: const Icon(Icons.tune),
|
||||||
|
|
@ -240,6 +257,7 @@ class _MarketScreenState extends State<MarketScreen> {
|
||||||
hasArea: _hasArea,
|
hasArea: _hasArea,
|
||||||
selfPubkey: widget.social.publicKeyHex,
|
selfPubkey: widget.social.publicKeyHex,
|
||||||
onOfferClosed: _reloadBlocked,
|
onOfferClosed: _reloadBlocked,
|
||||||
|
savedSearches: widget.savedSearches,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -253,11 +271,15 @@ class MarketBody extends StatelessWidget {
|
||||||
this.hasArea = true,
|
this.hasArea = true,
|
||||||
this.selfPubkey,
|
this.selfPubkey,
|
||||||
this.onOfferClosed,
|
this.onOfferClosed,
|
||||||
|
this.savedSearches,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
final VoidCallback onConfigure;
|
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).
|
/// Re-opens the connection (for the "can't reach servers" retry).
|
||||||
final VoidCallback? onRetry;
|
final VoidCallback? onRetry;
|
||||||
|
|
||||||
|
|
@ -346,6 +368,20 @@ class MarketBody extends StatelessWidget {
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: t.market.searchHint,
|
hintText: t.market.searchHint,
|
||||||
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
|
||||||
|
// (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),
|
hintStyle: const TextStyle(color: seedMuted),
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: Colors.white,
|
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"
|
/// One discovered offer. Human words for the reciprocity mode; coarse "near you"
|
||||||
/// instead of any precise location; price only when it's a sale.
|
/// instead of any precise location; price only when it's a sale.
|
||||||
class _OfferCard extends StatelessWidget {
|
class _OfferCard extends StatelessWidget {
|
||||||
|
|
|
||||||
282
apps/app_seeds/lib/ui/saved_searches_screen.dart
Normal file
282
apps/app_seeds/lib/ui/saved_searches_screen.dart
Normal file
|
|
@ -0,0 +1,282 @@
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:commons_core/commons_core.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import '../i18n/strings.g.dart';
|
||||||
|
import '../services/saved_searches_store.dart';
|
||||||
|
import 'market_widgets.dart';
|
||||||
|
import 'theme.dart';
|
||||||
|
|
||||||
|
/// The Wallapop-style "Saved searches": queries the user asked to be alerted
|
||||||
|
/// about. Tapping one runs it in the market (applying its query and facets);
|
||||||
|
/// each shows a badge when new matching offers have arrived while the app was
|
||||||
|
/// open. Delete removes the search and its alerts.
|
||||||
|
class SavedSearchesScreen extends StatefulWidget {
|
||||||
|
const SavedSearchesScreen({required this.store, super.key});
|
||||||
|
|
||||||
|
final SavedSearchesStore store;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<SavedSearchesScreen> createState() => _SavedSearchesScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SavedSearchesScreenState extends State<SavedSearchesScreen> {
|
||||||
|
List<SavedSearch>? _searches;
|
||||||
|
Map<String, int> _newCounts = const {};
|
||||||
|
StreamSubscription<void>? _changesSub;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_changesSub = widget.store.changes.listen((_) => _load());
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_changesSub?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
final searches = await widget.store.list();
|
||||||
|
final counts = <String, int>{
|
||||||
|
for (final s in searches) s.id: await widget.store.newMatchCount(s.id),
|
||||||
|
};
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_searches = searches;
|
||||||
|
_newCounts = counts;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs [search] in the market: mark it seen (clears its badge), then open the
|
||||||
|
/// market with the search applied.
|
||||||
|
Future<void> _open(SavedSearch search) async {
|
||||||
|
await widget.store.markViewed(search.id);
|
||||||
|
if (!mounted) return;
|
||||||
|
context.go('/market', extra: search);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _confirmDelete(SavedSearch search) async {
|
||||||
|
final t = context.t;
|
||||||
|
final ok = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
content: Text(t.savedSearches.deleteConfirm),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(false),
|
||||||
|
child: Text(t.common.cancel),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(true),
|
||||||
|
child: Text(t.savedSearches.delete),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (ok == true) await widget.store.remove(search.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final t = context.t;
|
||||||
|
final searches = _searches;
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: Text(t.savedSearches.title)),
|
||||||
|
body: searches == null
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: searches.isEmpty
|
||||||
|
? _Empty(text: t.savedSearches.empty)
|
||||||
|
: ListView.separated(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
itemCount: searches.length,
|
||||||
|
separatorBuilder: (_, _) => const SizedBox(height: 10),
|
||||||
|
itemBuilder: (context, i) {
|
||||||
|
final search = searches[i];
|
||||||
|
return _SavedSearchCard(
|
||||||
|
search: search,
|
||||||
|
newCount: _newCounts[search.id] ?? 0,
|
||||||
|
onOpen: () => _open(search),
|
||||||
|
onDelete: () => _confirmDelete(search),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An AppBar action that opens the saved-searches list, badged with the total
|
||||||
|
/// number of unseen matches. Stream-driven off the store's [changes], mirroring
|
||||||
|
/// how [UnreadBadge] tracks unread messages.
|
||||||
|
class SavedSearchesAction extends StatefulWidget {
|
||||||
|
const SavedSearchesAction({required this.store, super.key});
|
||||||
|
|
||||||
|
final SavedSearchesStore store;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<SavedSearchesAction> createState() => _SavedSearchesActionState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SavedSearchesActionState extends State<SavedSearchesAction> {
|
||||||
|
int _count = 0;
|
||||||
|
StreamSubscription<void>? _changesSub;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_changesSub = widget.store.changes.listen((_) => _refresh());
|
||||||
|
_refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_changesSub?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _refresh() async {
|
||||||
|
final count = await widget.store.totalNewMatchCount();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _count = count);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final t = context.t;
|
||||||
|
final icon = const Icon(Icons.bookmarks_outlined);
|
||||||
|
return IconButton(
|
||||||
|
key: const Key('market.savedSearches'),
|
||||||
|
icon: _count > 0
|
||||||
|
? Badge(
|
||||||
|
label: Text('$_count'),
|
||||||
|
backgroundColor: seedGreen,
|
||||||
|
child: icon,
|
||||||
|
)
|
||||||
|
: icon,
|
||||||
|
tooltip: t.savedSearches.openTooltip,
|
||||||
|
onPressed: () => context.push('/saved-searches'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SavedSearchCard extends StatelessWidget {
|
||||||
|
const _SavedSearchCard({
|
||||||
|
required this.search,
|
||||||
|
required this.newCount,
|
||||||
|
required this.onOpen,
|
||||||
|
required this.onDelete,
|
||||||
|
});
|
||||||
|
|
||||||
|
final SavedSearch search;
|
||||||
|
final int newCount;
|
||||||
|
final VoidCallback onOpen;
|
||||||
|
final VoidCallback onDelete;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final t = context.t;
|
||||||
|
final radius = BorderRadius.circular(14);
|
||||||
|
final facets = _facetSummary(t, search);
|
||||||
|
return Material(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: radius,
|
||||||
|
child: InkWell(
|
||||||
|
key: Key('savedSearches.item.${search.id}'),
|
||||||
|
onTap: onOpen,
|
||||||
|
borderRadius: radius,
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: radius,
|
||||||
|
border: Border.all(color: seedOutline),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
search.label,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 17,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: seedOnSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (facets.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
facets,
|
||||||
|
style: const TextStyle(color: seedMuted, fontSize: 13),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (newCount > 0) ...[
|
||||||
|
Badge(
|
||||||
|
label: Text(t.savedSearches.newMatchesBadge(n: newCount)),
|
||||||
|
backgroundColor: seedGreen,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
],
|
||||||
|
IconButton(
|
||||||
|
key: Key('savedSearches.delete.${search.id}'),
|
||||||
|
icon: const Icon(Icons.delete_outline, color: seedMuted),
|
||||||
|
tooltip: t.savedSearches.delete,
|
||||||
|
onPressed: onDelete,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A short, human line describing the search's facets (never the raw query,
|
||||||
|
/// which is already the label by default).
|
||||||
|
static String _facetSummary(Translations t, SavedSearch s) {
|
||||||
|
final parts = <String>[
|
||||||
|
for (final type in s.types) offerTypeLabel(t, type),
|
||||||
|
...s.categories,
|
||||||
|
if (s.organicOnly) t.editVariety.organic,
|
||||||
|
]..removeWhere((e) => e.isEmpty);
|
||||||
|
return parts.join(' · ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Empty extends StatelessWidget {
|
||||||
|
const _Empty({required this.text});
|
||||||
|
|
||||||
|
final String text;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.bookmark_border,
|
||||||
|
size: 64,
|
||||||
|
color: seedGreen.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
text,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
74
apps/app_seeds/test/ui/saved_searches_screen_test.dart
Normal file
74
apps/app_seeds/test/ui/saved_searches_screen_test.dart
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
import 'package:commons_core/commons_core.dart';
|
||||||
|
import 'package:flutter/material.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/saved_searches_store.dart';
|
||||||
|
import 'package:tane/ui/saved_searches_screen.dart';
|
||||||
|
|
||||||
|
import '../support/test_support.dart';
|
||||||
|
|
||||||
|
Widget _wrap(Widget child) {
|
||||||
|
LocaleSettings.setLocaleSync(AppLocale.en);
|
||||||
|
return TranslationProvider(
|
||||||
|
child: MaterialApp(
|
||||||
|
locale: AppLocale.en.flutterLocale,
|
||||||
|
supportedLocales: AppLocaleUtils.supportedLocales,
|
||||||
|
localizationsDelegates: const [
|
||||||
|
GlobalMaterialLocalizations.delegate,
|
||||||
|
GlobalWidgetsLocalizations.delegate,
|
||||||
|
GlobalCupertinoLocalizations.delegate,
|
||||||
|
],
|
||||||
|
home: child,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
SavedSearch _search(String id, {String label = 'Tomatoes'}) =>
|
||||||
|
SavedSearch(id: id, label: label, query: 'tomato', createdAt: 1);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('shows the empty state when nothing is saved', (tester) async {
|
||||||
|
final store = SavedSearchesStore(InMemorySecretStore());
|
||||||
|
addTearDown(store.close);
|
||||||
|
|
||||||
|
await tester.pumpWidget(_wrap(SavedSearchesScreen(store: store)));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
find.textContaining('No saved searches yet'),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('lists a saved search and its new-match badge', (tester) async {
|
||||||
|
final store = SavedSearchesStore(InMemorySecretStore());
|
||||||
|
addTearDown(store.close);
|
||||||
|
await store.save(_search('a'));
|
||||||
|
await store.markMatched('a', 'author:o1');
|
||||||
|
|
||||||
|
await tester.pumpWidget(_wrap(SavedSearchesScreen(store: store)));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Tomatoes'), findsOneWidget);
|
||||||
|
expect(find.text('1 new'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('delete confirms then removes the search', (tester) async {
|
||||||
|
final store = SavedSearchesStore(InMemorySecretStore());
|
||||||
|
addTearDown(store.close);
|
||||||
|
await store.save(_search('a'));
|
||||||
|
|
||||||
|
await tester.pumpWidget(_wrap(SavedSearchesScreen(store: store)));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('savedSearches.delete.a')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
// Confirm in the dialog.
|
||||||
|
await tester.tap(find.widgetWithText(FilledButton, 'Delete'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Tomatoes'), findsNothing);
|
||||||
|
expect(await store.exists('a'), isFalse);
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue