Compare commits
No commits in common. "87e87abc7c9abf19b0877c656c34ff4dcb2d0bf0" and "dd9c97a57a3a96eeb6be24c27b68ab421f6602a3" have entirely different histories.
87e87abc7c
...
dd9c97a57a
20 changed files with 20 additions and 1492 deletions
|
|
@ -18,7 +18,6 @@ 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';
|
||||||
|
|
@ -37,7 +36,6 @@ 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';
|
||||||
|
|
@ -64,7 +62,6 @@ 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,
|
||||||
|
|
@ -84,7 +81,6 @@ class TaneApp extends StatelessWidget {
|
||||||
profileStore,
|
profileStore,
|
||||||
profileCache,
|
profileCache,
|
||||||
savedOffers,
|
savedOffers,
|
||||||
savedSearches,
|
|
||||||
socialAccounts,
|
socialAccounts,
|
||||||
inbox,
|
inbox,
|
||||||
) {
|
) {
|
||||||
|
|
@ -92,8 +88,6 @@ 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;
|
||||||
|
|
@ -126,9 +120,6 @@ 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;
|
||||||
|
|
||||||
|
|
@ -158,7 +149,6 @@ 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,
|
||||||
) {
|
) {
|
||||||
|
|
@ -180,7 +170,6 @@ 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)
|
||||||
|
|
@ -207,12 +196,6 @@ 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,8 +20,6 @@ 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';
|
||||||
|
|
@ -72,16 +70,12 @@ 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;
|
||||||
final savedSearchAlerts = getIt.isRegistered<SavedSearchAlertService>()
|
// Subscribe the inbox + sync + plantaré listeners BEFORE the shared
|
||||||
? getIt<SavedSearchAlertService>()
|
// connection starts connecting, so the first session is caught; then bring
|
||||||
: null;
|
// the connection up.
|
||||||
// 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(
|
||||||
|
|
@ -98,7 +92,6 @@ 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,8 +41,6 @@ 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';
|
||||||
|
|
@ -191,9 +189,6 @@ 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,
|
||||||
|
|
@ -278,18 +273,6 @@ 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),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -326,10 +309,6 @@ 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>();
|
||||||
|
|
@ -345,8 +324,6 @@ 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
|
||||||
|
|
@ -362,9 +339,6 @@ 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),
|
||||||
);
|
);
|
||||||
|
|
@ -399,26 +373,16 @@ 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,19 +12,6 @@
|
||||||
"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,19 +12,6 @@
|
||||||
"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",
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,9 @@
|
||||||
/// To regenerate, run: `dart run slang`
|
/// To regenerate, run: `dart run slang`
|
||||||
///
|
///
|
||||||
/// Locales: 7
|
/// Locales: 7
|
||||||
/// Strings: 3494 (499 per locale)
|
/// Strings: 3472 (496 per locale)
|
||||||
///
|
///
|
||||||
/// Built on 2026-07-17 at 11:10 UTC
|
/// Built on 2026-07-17 at 07:24 UTC
|
||||||
|
|
||||||
// coverage:ignore-file
|
// coverage:ignore-file
|
||||||
// ignore_for_file: type=lint, unused_import
|
// ignore_for_file: type=lint, unused_import
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,6 @@ class Translations with BaseTranslations<AppLocale, Translations> {
|
||||||
// Translations
|
// Translations
|
||||||
late final Translations$avatar$en avatar = Translations$avatar$en.internal(_root);
|
late final Translations$avatar$en avatar = Translations$avatar$en.internal(_root);
|
||||||
late final Translations$favorites$en favorites = Translations$favorites$en.internal(_root);
|
late final Translations$favorites$en favorites = Translations$favorites$en.internal(_root);
|
||||||
late final Translations$savedSearches$en savedSearches = Translations$savedSearches$en.internal(_root);
|
|
||||||
late final Translations$seedSaving$en seedSaving = Translations$seedSaving$en.internal(_root);
|
late final Translations$seedSaving$en seedSaving = Translations$seedSaving$en.internal(_root);
|
||||||
late final Translations$calendar$en calendar = Translations$calendar$en.internal(_root);
|
late final Translations$calendar$en calendar = Translations$calendar$en.internal(_root);
|
||||||
late final Translations$app$en app = Translations$app$en.internal(_root);
|
late final Translations$app$en app = Translations$app$en.internal(_root);
|
||||||
|
|
@ -138,48 +137,6 @@ class Translations$favorites$en {
|
||||||
String get unavailable => 'No longer available';
|
String get unavailable => 'No longer available';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Path: savedSearches
|
|
||||||
class Translations$savedSearches$en {
|
|
||||||
Translations$savedSearches$en.internal(this._root);
|
|
||||||
|
|
||||||
final Translations _root; // ignore: unused_field
|
|
||||||
|
|
||||||
// Translations
|
|
||||||
|
|
||||||
/// en: 'Saved searches'
|
|
||||||
String get title => 'Saved searches';
|
|
||||||
|
|
||||||
/// en: 'No saved searches yet. Save a search from the market and we'll tell you when something like it appears in your zone.'
|
|
||||||
String get empty => 'No saved searches yet. Save a search from the market and we\'ll tell you when something like it appears in your zone.';
|
|
||||||
|
|
||||||
/// en: 'Save this search'
|
|
||||||
String get save => 'Save this search';
|
|
||||||
|
|
||||||
/// en: 'Name this search'
|
|
||||||
String get nameLabel => 'Name this search';
|
|
||||||
|
|
||||||
/// en: 'e.g. Tomatoes near me'
|
|
||||||
String get namePlaceholder => 'e.g. Tomatoes near me';
|
|
||||||
|
|
||||||
/// en: 'Search saved — we'll alert you about new matches nearby'
|
|
||||||
String get saved => 'Search saved — we\'ll alert you about new matches nearby';
|
|
||||||
|
|
||||||
/// en: 'Saved searches'
|
|
||||||
String get openTooltip => 'Saved searches';
|
|
||||||
|
|
||||||
/// en: 'Delete'
|
|
||||||
String get delete => 'Delete';
|
|
||||||
|
|
||||||
/// en: 'Delete this saved search?'
|
|
||||||
String get deleteConfirm => 'Delete this saved search?';
|
|
||||||
|
|
||||||
/// en: '{n} new'
|
|
||||||
String newMatchesBadge({required Object n}) => '${n} new';
|
|
||||||
|
|
||||||
/// en: 'New seeds near you: {label}'
|
|
||||||
String alert({required Object label}) => 'New seeds near you: ${label}';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Path: seedSaving
|
// Path: seedSaving
|
||||||
class Translations$seedSaving$en {
|
class Translations$seedSaving$en {
|
||||||
Translations$seedSaving$en.internal(this._root);
|
Translations$seedSaving$en.internal(this._root);
|
||||||
|
|
@ -2659,17 +2616,6 @@ extension on Translations {
|
||||||
'favorites.save' => 'Save to favorites',
|
'favorites.save' => 'Save to favorites',
|
||||||
'favorites.remove' => 'Remove from favorites',
|
'favorites.remove' => 'Remove from favorites',
|
||||||
'favorites.unavailable' => 'No longer available',
|
'favorites.unavailable' => 'No longer available',
|
||||||
'savedSearches.title' => 'Saved searches',
|
|
||||||
'savedSearches.empty' => 'No saved searches yet. Save a search from the market and we\'ll tell you when something like it appears in your zone.',
|
|
||||||
'savedSearches.save' => 'Save this search',
|
|
||||||
'savedSearches.nameLabel' => 'Name this search',
|
|
||||||
'savedSearches.namePlaceholder' => 'e.g. Tomatoes near me',
|
|
||||||
'savedSearches.saved' => 'Search saved — we\'ll alert you about new matches nearby',
|
|
||||||
'savedSearches.openTooltip' => 'Saved searches',
|
|
||||||
'savedSearches.delete' => 'Delete',
|
|
||||||
'savedSearches.deleteConfirm' => 'Delete this saved search?',
|
|
||||||
'savedSearches.newMatchesBadge' => ({required Object n}) => '${n} new',
|
|
||||||
'savedSearches.alert' => ({required Object label}) => 'New seeds near you: ${label}',
|
|
||||||
'seedSaving.title' => 'Saving its seed',
|
'seedSaving.title' => 'Saving its seed',
|
||||||
'seedSaving.subtitle' => 'What it takes to keep the variety true',
|
'seedSaving.subtitle' => 'What it takes to keep the variety true',
|
||||||
'seedSaving.lifeCycle' => 'Cycle',
|
'seedSaving.lifeCycle' => 'Cycle',
|
||||||
|
|
@ -3162,8 +3108,6 @@ extension on Translations {
|
||||||
'plantare.dueByHint' => 'A gentle reminder, never enforced',
|
'plantare.dueByHint' => 'A gentle reminder, never enforced',
|
||||||
'plantare.pickDate' => 'Pick a date',
|
'plantare.pickDate' => 'Pick a date',
|
||||||
'plantare.clearDate' => 'Clear date',
|
'plantare.clearDate' => 'Clear date',
|
||||||
_ => null,
|
|
||||||
} ?? switch (path) {
|
|
||||||
'plantare.sectionTitle' => 'Commitments',
|
'plantare.sectionTitle' => 'Commitments',
|
||||||
'plantare.propose' => 'Propose a signed Plantaré',
|
'plantare.propose' => 'Propose a signed Plantaré',
|
||||||
'plantare.proposeHelp' => 'Both of you keep the same promise, signed by both — proof that this seed changed hands and will be grown out and returned.',
|
'plantare.proposeHelp' => 'Both of you keep the same promise, signed by both — proof that this seed changed hands and will be grown out and returned.',
|
||||||
|
|
@ -3175,6 +3119,8 @@ extension on Translations {
|
||||||
'plantare.returnSimilar' => 'A similar amount of seed',
|
'plantare.returnSimilar' => 'A similar amount of seed',
|
||||||
'plantare.returnSimilarNote' => 'open-pollinated · non-GMO · organically grown',
|
'plantare.returnSimilarNote' => 'open-pollinated · non-GMO · organically grown',
|
||||||
'plantare.returnWork' => 'Some hours of work',
|
'plantare.returnWork' => 'Some hours of work',
|
||||||
|
_ => null,
|
||||||
|
} ?? switch (path) {
|
||||||
'plantare.returnOther' => 'Something else',
|
'plantare.returnOther' => 'Something else',
|
||||||
'plantare.workHoursLabel' => 'How many hours?',
|
'plantare.workHoursLabel' => 'How many hours?',
|
||||||
'plantare.proposalsSection' => 'Waiting for your reply',
|
'plantare.proposalsSection' => 'Waiting for your reply',
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,6 @@ class TranslationsEs extends Translations with BaseTranslations<AppLocale, Trans
|
||||||
// Translations
|
// Translations
|
||||||
@override late final _Translations$avatar$es avatar = _Translations$avatar$es._(_root);
|
@override late final _Translations$avatar$es avatar = _Translations$avatar$es._(_root);
|
||||||
@override late final _Translations$favorites$es favorites = _Translations$favorites$es._(_root);
|
@override late final _Translations$favorites$es favorites = _Translations$favorites$es._(_root);
|
||||||
@override late final _Translations$savedSearches$es savedSearches = _Translations$savedSearches$es._(_root);
|
|
||||||
@override late final _Translations$seedSaving$es seedSaving = _Translations$seedSaving$es._(_root);
|
@override late final _Translations$seedSaving$es seedSaving = _Translations$seedSaving$es._(_root);
|
||||||
@override late final _Translations$calendar$es calendar = _Translations$calendar$es._(_root);
|
@override late final _Translations$calendar$es calendar = _Translations$calendar$es._(_root);
|
||||||
@override late final _Translations$app$es app = _Translations$app$es._(_root);
|
@override late final _Translations$app$es app = _Translations$app$es._(_root);
|
||||||
|
|
@ -119,26 +118,6 @@ class _Translations$favorites$es extends Translations$favorites$en {
|
||||||
@override String get unavailable => 'Ya no está disponible';
|
@override String get unavailable => 'Ya no está disponible';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Path: savedSearches
|
|
||||||
class _Translations$savedSearches$es extends Translations$savedSearches$en {
|
|
||||||
_Translations$savedSearches$es._(TranslationsEs root) : this._root = root, super.internal(root);
|
|
||||||
|
|
||||||
final TranslationsEs _root; // ignore: unused_field
|
|
||||||
|
|
||||||
// Translations
|
|
||||||
@override String get title => 'Búsquedas guardadas';
|
|
||||||
@override String get 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.';
|
|
||||||
@override String get save => 'Guardar esta búsqueda';
|
|
||||||
@override String get nameLabel => 'Nombra esta búsqueda';
|
|
||||||
@override String get namePlaceholder => 'p. ej. Tomates cerca';
|
|
||||||
@override String get saved => 'Búsqueda guardada — te avisaremos de lo nuevo que encaje cerca';
|
|
||||||
@override String get openTooltip => 'Búsquedas guardadas';
|
|
||||||
@override String get delete => 'Eliminar';
|
|
||||||
@override String get deleteConfirm => '¿Eliminar esta búsqueda guardada?';
|
|
||||||
@override String newMatchesBadge({required Object n}) => '${n} nuevas';
|
|
||||||
@override String alert({required Object label}) => 'Semillas nuevas cerca: ${label}';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Path: seedSaving
|
// Path: seedSaving
|
||||||
class _Translations$seedSaving$es extends Translations$seedSaving$en {
|
class _Translations$seedSaving$es extends Translations$seedSaving$en {
|
||||||
_Translations$seedSaving$es._(TranslationsEs root) : this._root = root, super.internal(root);
|
_Translations$seedSaving$es._(TranslationsEs root) : this._root = root, super.internal(root);
|
||||||
|
|
@ -1472,17 +1451,6 @@ extension on TranslationsEs {
|
||||||
'favorites.save' => 'Guardar en favoritos',
|
'favorites.save' => 'Guardar en favoritos',
|
||||||
'favorites.remove' => 'Quitar de favoritos',
|
'favorites.remove' => 'Quitar de favoritos',
|
||||||
'favorites.unavailable' => 'Ya no está disponible',
|
'favorites.unavailable' => 'Ya no está disponible',
|
||||||
'savedSearches.title' => 'Búsquedas guardadas',
|
|
||||||
'savedSearches.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.',
|
|
||||||
'savedSearches.save' => 'Guardar esta búsqueda',
|
|
||||||
'savedSearches.nameLabel' => 'Nombra esta búsqueda',
|
|
||||||
'savedSearches.namePlaceholder' => 'p. ej. Tomates cerca',
|
|
||||||
'savedSearches.saved' => 'Búsqueda guardada — te avisaremos de lo nuevo que encaje cerca',
|
|
||||||
'savedSearches.openTooltip' => 'Búsquedas guardadas',
|
|
||||||
'savedSearches.delete' => 'Eliminar',
|
|
||||||
'savedSearches.deleteConfirm' => '¿Eliminar esta búsqueda guardada?',
|
|
||||||
'savedSearches.newMatchesBadge' => ({required Object n}) => '${n} nuevas',
|
|
||||||
'savedSearches.alert' => ({required Object label}) => 'Semillas nuevas cerca: ${label}',
|
|
||||||
'seedSaving.title' => 'Conservar su semilla',
|
'seedSaving.title' => 'Conservar su semilla',
|
||||||
'seedSaving.subtitle' => 'Lo que hace falta para mantener la variedad fiel',
|
'seedSaving.subtitle' => 'Lo que hace falta para mantener la variedad fiel',
|
||||||
'seedSaving.lifeCycle' => 'Ciclo',
|
'seedSaving.lifeCycle' => 'Ciclo',
|
||||||
|
|
@ -1975,8 +1943,6 @@ extension on TranslationsEs {
|
||||||
'plantare.pickDate' => 'Elegir fecha',
|
'plantare.pickDate' => 'Elegir fecha',
|
||||||
'plantare.clearDate' => 'Quitar fecha',
|
'plantare.clearDate' => 'Quitar fecha',
|
||||||
'plantare.sectionTitle' => 'Compromisos',
|
'plantare.sectionTitle' => 'Compromisos',
|
||||||
_ => null,
|
|
||||||
} ?? switch (path) {
|
|
||||||
'plantare.propose' => 'Proponer un Plantaré firmado',
|
'plantare.propose' => 'Proponer un Plantaré firmado',
|
||||||
'plantare.proposeHelp' => 'Los dos guardáis la misma promesa, firmada por ambos: prueba de que esta semilla cambió de manos y se cultivará y devolverá.',
|
'plantare.proposeHelp' => 'Los dos guardáis la misma promesa, firmada por ambos: prueba de que esta semilla cambió de manos y se cultivará y devolverá.',
|
||||||
'plantare.proposeTo' => ({required Object name}) => 'Con ${name}',
|
'plantare.proposeTo' => ({required Object name}) => 'Con ${name}',
|
||||||
|
|
@ -1988,6 +1954,8 @@ extension on TranslationsEs {
|
||||||
'plantare.returnSimilarNote' => 'polinización abierta · no transgénica · cultivada en ecológico',
|
'plantare.returnSimilarNote' => 'polinización abierta · no transgénica · cultivada en ecológico',
|
||||||
'plantare.returnWork' => 'Unas horas de trabajo',
|
'plantare.returnWork' => 'Unas horas de trabajo',
|
||||||
'plantare.returnOther' => 'Otra cosa',
|
'plantare.returnOther' => 'Otra cosa',
|
||||||
|
_ => null,
|
||||||
|
} ?? switch (path) {
|
||||||
'plantare.workHoursLabel' => '¿Cuántas horas?',
|
'plantare.workHoursLabel' => '¿Cuántas horas?',
|
||||||
'plantare.proposalsSection' => 'Esperando tu respuesta',
|
'plantare.proposalsSection' => 'Esperando tu respuesta',
|
||||||
'plantare.incomingFrom' => ({required Object name}) => '${name} te propone un Plantaré',
|
'plantare.incomingFrom' => ({required Object name}) => '${name} te propone un Plantaré',
|
||||||
|
|
|
||||||
|
|
@ -25,16 +25,7 @@ class NotificationService {
|
||||||
/// app wires this to `router.push('/chat/<pubkey>')` once the router exists.
|
/// app wires this to `router.push('/chat/<pubkey>')` once the router exists.
|
||||||
void Function(String peerPubkey)? onTapChat;
|
void Function(String peerPubkey)? onTapChat;
|
||||||
|
|
||||||
/// Called with a saved-search id when the user taps a search-alert
|
|
||||||
/// notification. The app wires this to open the saved-searches list.
|
|
||||||
void Function(String searchId)? onTapSearch;
|
|
||||||
|
|
||||||
static const _channelId = 'messages';
|
static const _channelId = 'messages';
|
||||||
static const _alertsChannelId = 'alerts';
|
|
||||||
|
|
||||||
/// Tap payloads are prefixed so one handler can route by kind. A bare payload
|
|
||||||
/// (no prefix) stays a chat peer pubkey, for backward compatibility.
|
|
||||||
static const _searchPayloadPrefix = 'search:';
|
|
||||||
|
|
||||||
static bool get _platformSupported {
|
static bool get _platformSupported {
|
||||||
if (kIsWeb) return false;
|
if (kIsWeb) return false;
|
||||||
|
|
@ -66,12 +57,7 @@ class NotificationService {
|
||||||
),
|
),
|
||||||
onDidReceiveNotificationResponse: (response) {
|
onDidReceiveNotificationResponse: (response) {
|
||||||
final payload = response.payload;
|
final payload = response.payload;
|
||||||
if (payload == null || payload.isEmpty) return;
|
if (payload != null && payload.isNotEmpty) onTapChat?.call(payload);
|
||||||
if (payload.startsWith(_searchPayloadPrefix)) {
|
|
||||||
onTapSearch?.call(payload.substring(_searchPayloadPrefix.length));
|
|
||||||
} else {
|
|
||||||
onTapChat?.call(payload);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
await _plugin
|
await _plugin
|
||||||
|
|
@ -114,35 +100,4 @@ class NotificationService {
|
||||||
await _plugin.show(peerPubkey.hashCode, title, null, details,
|
await _plugin.show(peerPubkey.hashCode, title, null, details,
|
||||||
payload: peerPubkey);
|
payload: peerPubkey);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shows a notification for a new offer matching a saved search. [title] is a
|
|
||||||
/// generic line built by the caller (e.g. "New match: Tomatoes"); [searchId]
|
|
||||||
/// rides along so a tap opens that search. No-op on unsupported platforms.
|
|
||||||
Future<void> showSearchAlert({
|
|
||||||
required String searchId,
|
|
||||||
required String title,
|
|
||||||
}) async {
|
|
||||||
if (!_supported) return;
|
|
||||||
const details = NotificationDetails(
|
|
||||||
android: AndroidNotificationDetails(
|
|
||||||
_alertsChannelId,
|
|
||||||
'Search alerts',
|
|
||||||
channelDescription: 'New offers matching your saved searches',
|
|
||||||
importance: Importance.high,
|
|
||||||
priority: Priority.high,
|
|
||||||
),
|
|
||||||
iOS: DarwinNotificationDetails(),
|
|
||||||
macOS: DarwinNotificationDetails(),
|
|
||||||
linux: LinuxNotificationDetails(),
|
|
||||||
);
|
|
||||||
// One notification per search (same id replaces the previous), so repeated
|
|
||||||
// matches for one search don't stack up.
|
|
||||||
await _plugin.show(
|
|
||||||
_searchPayloadPrefix.hashCode ^ searchId.hashCode,
|
|
||||||
title,
|
|
||||||
null,
|
|
||||||
details,
|
|
||||||
payload: '$_searchPayloadPrefix$searchId',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,147 +0,0 @@
|
||||||
import 'dart:async';
|
|
||||||
|
|
||||||
import 'package:commons_core/commons_core.dart';
|
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
|
|
||||||
import 'discovery_area.dart';
|
|
||||||
import 'notification_service.dart';
|
|
||||||
import 'saved_searches_store.dart';
|
|
||||||
import 'social_connection.dart';
|
|
||||||
import 'social_service.dart' show SocialSession;
|
|
||||||
import 'social_settings.dart';
|
|
||||||
|
|
||||||
/// Builds the notification title for a fresh match on [search]. Injected so the
|
|
||||||
/// app can supply a localized string while tests stay i18n-free.
|
|
||||||
typedef AlertTitle = String Function(SavedSearch search);
|
|
||||||
|
|
||||||
/// App-wide listener that turns incoming market offers into saved-search alerts
|
|
||||||
/// (Wallapop's "búsquedas favoritas"). One discovery subscription over the
|
|
||||||
/// SHARED [SocialConnection] for the user's whole current zone; each offer is
|
|
||||||
/// matched locally against every saved search.
|
|
||||||
///
|
|
||||||
/// The discover stream replays a relay's stored offers before going live, so
|
|
||||||
/// this doubles as the catch-up scan on app start — the per-search seen-key
|
|
||||||
/// dedup in [SavedSearchesStore] keeps replays and edits silent, so only
|
|
||||||
/// genuinely new matches notify. It re-subscribes when the connection
|
|
||||||
/// reconnects and when the user's search area changes.
|
|
||||||
///
|
|
||||||
/// Foreground only — background/push delivery is a later, larger concern.
|
|
||||||
class SavedSearchAlertService {
|
|
||||||
SavedSearchAlertService({
|
|
||||||
required SocialConnection connection,
|
|
||||||
required String selfPubkey,
|
|
||||||
required SavedSearchesStore store,
|
|
||||||
required SocialSettings settings,
|
|
||||||
NotificationService? notifications,
|
|
||||||
AlertTitle? alertTitle,
|
|
||||||
}) : _connection = connection,
|
|
||||||
_selfPubkey = selfPubkey,
|
|
||||||
_store = store,
|
|
||||||
_settings = settings,
|
|
||||||
_notifications = notifications,
|
|
||||||
_alertTitle = alertTitle ?? ((s) => s.label);
|
|
||||||
|
|
||||||
final SocialConnection _connection;
|
|
||||||
final String _selfPubkey;
|
|
||||||
final SavedSearchesStore _store;
|
|
||||||
final SocialSettings _settings;
|
|
||||||
final NotificationService? _notifications;
|
|
||||||
final AlertTitle _alertTitle;
|
|
||||||
|
|
||||||
StreamSubscription<SocialSession?>? _sessionsSub;
|
|
||||||
StreamSubscription<Offer>? _offersSub;
|
|
||||||
SocialSession? _session;
|
|
||||||
String _prefix = '';
|
|
||||||
bool _started = false;
|
|
||||||
|
|
||||||
/// Begins listening. Subscribe to the connection's sessions BEFORE it starts
|
|
||||||
/// connecting, so the first session is caught too.
|
|
||||||
void start() {
|
|
||||||
if (_started) return;
|
|
||||||
_started = true;
|
|
||||||
_sessionsSub = _connection.sessions.listen(_onSession);
|
|
||||||
final current = _connection.current;
|
|
||||||
if (current != null) _onSession(current);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Re-subscribes discovery, e.g. after the user changes their search area.
|
|
||||||
/// Cheap no-op when the session and prefix are unchanged.
|
|
||||||
Future<void> refreshArea() async {
|
|
||||||
final session = _session;
|
|
||||||
if (session != null) await _bind(session, force: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onSession(SocialSession? session) {
|
|
||||||
if (identical(session, _session) && _offersSub != null) return;
|
|
||||||
_session = session;
|
|
||||||
if (session == null) {
|
|
||||||
unawaited(_offersSub?.cancel());
|
|
||||||
_offersSub = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
unawaited(_bind(session, force: true));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// (Re)binds the offer subscription to [session] over the user's current
|
|
||||||
/// zone. Skips re-subscribing when nothing changed unless [force].
|
|
||||||
Future<void> _bind(SocialSession session, {bool force = false}) async {
|
|
||||||
final area = await _settings.areaGeohash();
|
|
||||||
if (area.isEmpty) {
|
|
||||||
// No zone set yet → nothing to search. Drop any stale subscription.
|
|
||||||
await _offersSub?.cancel();
|
|
||||||
_offersSub = null;
|
|
||||||
_prefix = '';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final prefix = searchPrefix(area, await _settings.searchPrecision());
|
|
||||||
if (!force && prefix == _prefix && _offersSub != null) return;
|
|
||||||
await _offersSub?.cancel();
|
|
||||||
_prefix = prefix;
|
|
||||||
_offersSub = session.offers
|
|
||||||
.discover(DiscoveryQuery(geohashPrefix: prefix))
|
|
||||||
.listen(
|
|
||||||
ingest,
|
|
||||||
onError: (_) {}, // handled by the connection's reconnect
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Matches one incoming [offer] against every saved search and fires an alert
|
|
||||||
/// for each fresh match. A testable seam (no relay).
|
|
||||||
@visibleForTesting
|
|
||||||
Future<void> ingest(Offer offer) async {
|
|
||||||
// Never alert on your own listings.
|
|
||||||
if (offer.authorPubkeyHex == _selfPubkey) return;
|
|
||||||
// Only live offers are worth surfacing.
|
|
||||||
if (offer.status != OfferLifecycle.active) return;
|
|
||||||
// Respect the same moderation the market applies.
|
|
||||||
if (await _settings.isBlocked(offer.authorPubkeyHex)) return;
|
|
||||||
final hidden = await _settings.hiddenOfferKeys();
|
|
||||||
if (hidden.contains(SocialSettings.offerKey(offer.authorPubkeyHex, offer.id))) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final searches = await _store.list();
|
|
||||||
if (searches.isEmpty) return;
|
|
||||||
final key = SavedSearchesStore.keyOf(offer);
|
|
||||||
for (final search in searches) {
|
|
||||||
if (!search.matches(offer)) continue;
|
|
||||||
final isNew = await _store.markMatched(search.id, key);
|
|
||||||
if (isNew) {
|
|
||||||
await _notifications?.showSearchAlert(
|
|
||||||
searchId: search.id,
|
|
||||||
title: _alertTitle(search),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> stop() async {
|
|
||||||
_started = false;
|
|
||||||
await _sessionsSub?.cancel();
|
|
||||||
_sessionsSub = null;
|
|
||||||
await _offersSub?.cancel();
|
|
||||||
_offersSub = null;
|
|
||||||
_session = null;
|
|
||||||
_prefix = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,162 +0,0 @@
|
||||||
import 'dart:async';
|
|
||||||
import 'dart:convert';
|
|
||||||
|
|
||||||
import 'package:commons_core/commons_core.dart';
|
|
||||||
|
|
||||||
import '../security/secret_store.dart';
|
|
||||||
|
|
||||||
/// A person's saved market searches with their alert bookkeeping — the query
|
|
||||||
/// and facets they want to be notified about, Wallapop-style. Keystore-backed
|
|
||||||
/// (no plaintext at rest) and namespaced by the active identity's
|
|
||||||
/// [accountScope], like [SavedOffersStore].
|
|
||||||
///
|
|
||||||
/// Per search we also persist:
|
|
||||||
/// - [_seenKeysField]: offer coordinates (`authorPubkeyHex:id`) already alerted
|
|
||||||
/// on, so a relay replaying stored/edited events never re-notifies. Capped at
|
|
||||||
/// [_seenCap] (oldest dropped) to keep the blob small.
|
|
||||||
/// - [_newKeysField]: matches the user hasn't looked at yet — the badge source,
|
|
||||||
/// cleared when they open the search.
|
|
||||||
class SavedSearchesStore {
|
|
||||||
SavedSearchesStore(this._store, {String accountScope = ''})
|
|
||||||
: _key = accountScope.isEmpty
|
|
||||||
? 'tane.social.saved_searches'
|
|
||||||
: 'tane.social.$accountScope.saved_searches';
|
|
||||||
|
|
||||||
final SecretStore _store;
|
|
||||||
final String _key;
|
|
||||||
|
|
||||||
static const _seenCap = 500;
|
|
||||||
static const _seenKeysField = 'seenKeys';
|
|
||||||
static const _newKeysField = 'newKeys';
|
|
||||||
|
|
||||||
final _changes = StreamController<void>.broadcast();
|
|
||||||
|
|
||||||
/// Emits whenever the stored searches or their alert counts change, so open
|
|
||||||
/// screens and badges can refresh.
|
|
||||||
Stream<void> get changes => _changes.stream;
|
|
||||||
|
|
||||||
/// The stable per-offer key used for dedup: its addressable coordinate
|
|
||||||
/// (`authorPubkeyHex:id`), matching [SavedOffersStore.keyOf].
|
|
||||||
static String keyOf(Offer offer) => '${offer.authorPubkeyHex}:${offer.id}';
|
|
||||||
|
|
||||||
/// Every saved search, newest first (by creation time).
|
|
||||||
Future<List<SavedSearch>> list() async {
|
|
||||||
final entries = await _rawList()
|
|
||||||
..sort((a, b) =>
|
|
||||||
((b['createdAt'] as int?) ?? 0).compareTo((a['createdAt'] as int?) ?? 0));
|
|
||||||
return [for (final e in entries) SavedSearch.fromJson(e)];
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether a search with [id] exists.
|
|
||||||
Future<bool> exists(String id) async =>
|
|
||||||
(await _rawList()).any((e) => e['id'] == id);
|
|
||||||
|
|
||||||
/// Saves [search] (idempotent on its id). [seedSeenKeys] pre-marks offers the
|
|
||||||
/// user has already seen (the current on-screen matches), so they aren't
|
|
||||||
/// alerted about listings that were visible when they saved the search.
|
|
||||||
Future<void> save(
|
|
||||||
SavedSearch search, {
|
|
||||||
Iterable<String> seedSeenKeys = const [],
|
|
||||||
}) async {
|
|
||||||
final list = await _rawList();
|
|
||||||
final existing = _find(list, search.id);
|
|
||||||
final seen = <String>{
|
|
||||||
...?(existing?[_seenKeysField] as List?)?.cast<String>(),
|
|
||||||
...seedSeenKeys,
|
|
||||||
};
|
|
||||||
list
|
|
||||||
..removeWhere((e) => e['id'] == search.id)
|
|
||||||
..add({
|
|
||||||
...search.toJson(),
|
|
||||||
_seenKeysField: _capped(seen.toList()),
|
|
||||||
// Keep any unread matches on re-save (e.g. an edit), else start empty.
|
|
||||||
_newKeysField:
|
|
||||||
(existing?[_newKeysField] as List?)?.cast<String>() ?? const [],
|
|
||||||
});
|
|
||||||
await _writeRaw(list);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Removes the search with [id]; no-op if absent.
|
|
||||||
Future<void> remove(String id) async {
|
|
||||||
final list = await _rawList()..removeWhere((e) => e['id'] == id);
|
|
||||||
await _writeRaw(list);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Records that [offerKey] matched search [id]: adds it to both the seen set
|
|
||||||
/// (so it never re-alerts) and the unread set (so the badge shows it).
|
|
||||||
/// Returns true when this is a fresh match worth notifying about; false when
|
|
||||||
/// the key was already seen (a replay/echo) or the search is gone.
|
|
||||||
Future<bool> markMatched(String id, String offerKey) async {
|
|
||||||
final list = await _rawList();
|
|
||||||
final entry = _find(list, id);
|
|
||||||
if (entry == null) return false;
|
|
||||||
final seen = (entry[_seenKeysField] as List?)?.cast<String>() ?? const [];
|
|
||||||
if (seen.contains(offerKey)) return false;
|
|
||||||
entry[_seenKeysField] = _capped([...seen, offerKey]);
|
|
||||||
final unread = (entry[_newKeysField] as List?)?.cast<String>() ?? const [];
|
|
||||||
if (!unread.contains(offerKey)) {
|
|
||||||
entry[_newKeysField] = [...unread, offerKey];
|
|
||||||
}
|
|
||||||
await _writeRaw(list);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clears the unread matches for search [id] (the user opened it).
|
|
||||||
Future<void> markViewed(String id) async {
|
|
||||||
final list = await _rawList();
|
|
||||||
final entry = _find(list, id);
|
|
||||||
if (entry == null) return;
|
|
||||||
if (((entry[_newKeysField] as List?) ?? const []).isEmpty) return;
|
|
||||||
entry[_newKeysField] = const [];
|
|
||||||
await _writeRaw(list);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Unread match count for a single search.
|
|
||||||
Future<int> newMatchCount(String id) async {
|
|
||||||
final entry = _find(await _rawList(), id);
|
|
||||||
return ((entry?[_newKeysField] as List?) ?? const []).length;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Total unread matches across every saved search (the app-level badge).
|
|
||||||
Future<int> totalNewMatchCount() async {
|
|
||||||
var total = 0;
|
|
||||||
for (final e in await _rawList()) {
|
|
||||||
total += ((e[_newKeysField] as List?) ?? const []).length;
|
|
||||||
}
|
|
||||||
return total;
|
|
||||||
}
|
|
||||||
|
|
||||||
static Map<String, dynamic>? _find(
|
|
||||||
List<Map<String, dynamic>> list,
|
|
||||||
String id,
|
|
||||||
) {
|
|
||||||
for (final e in list) {
|
|
||||||
if (e['id'] == id) return e;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Keeps only the newest [_seenCap] keys (list is append-ordered, so drop
|
|
||||||
/// from the front).
|
|
||||||
static List<String> _capped(List<String> keys) => keys.length <= _seenCap
|
|
||||||
? keys
|
|
||||||
: keys.sublist(keys.length - _seenCap);
|
|
||||||
|
|
||||||
Future<List<Map<String, dynamic>>> _rawList() async {
|
|
||||||
final raw = await _store.read(_key);
|
|
||||||
if (raw == null || raw.isEmpty) return [];
|
|
||||||
return (jsonDecode(raw) as List)
|
|
||||||
.cast<Map<String, dynamic>>()
|
|
||||||
.map((e) => Map<String, dynamic>.from(e))
|
|
||||||
.toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _writeRaw(List<Map<String, dynamic>> list) async {
|
|
||||||
await _store.write(_key, jsonEncode(list));
|
|
||||||
if (!_changes.isClosed) _changes.add(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> close() async {
|
|
||||||
await _changes.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -69,21 +69,20 @@ class OffersState extends Equatable {
|
||||||
/// [offers] narrowed by the text [query] and the chip filters (all ANDed).
|
/// [offers] narrowed by the text [query] and the chip filters (all ANDed).
|
||||||
/// Kept separate from [offers] so filtering never drops the discoveries.
|
/// Kept separate from [offers] so filtering never drops the discoveries.
|
||||||
List<Offer> get visibleOffers {
|
List<Offer> get visibleOffers {
|
||||||
|
final q = query.trim().toLowerCase();
|
||||||
return offers.where((o) {
|
return offers.where((o) {
|
||||||
// Moderation state (block/hide) is app-side, not part of the search facets.
|
|
||||||
if (blockedAuthors.contains(o.authorPubkeyHex)) return false;
|
if (blockedAuthors.contains(o.authorPubkeyHex)) return false;
|
||||||
if (hiddenOfferKeys.contains('${o.authorPubkeyHex}:${o.id}')) {
|
if (hiddenOfferKeys.contains('${o.authorPubkeyHex}:${o.id}')) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// The query/facet chain is shared with saved-search alerts so the two
|
if (q.isNotEmpty && !o.summary.toLowerCase().contains(q)) return false;
|
||||||
// never disagree on what "matches".
|
if (typeFilter.isNotEmpty && !typeFilter.contains(o.type)) return false;
|
||||||
return SavedSearch.matchesFilters(
|
if (categoryFilter.isNotEmpty &&
|
||||||
o,
|
(o.category == null || !categoryFilter.contains(o.category))) {
|
||||||
query: query,
|
return false;
|
||||||
types: typeFilter,
|
}
|
||||||
categories: categoryFilter,
|
if (organicOnly && !o.isOrganic) return false;
|
||||||
organicOnly: organicOnly,
|
return true;
|
||||||
);
|
|
||||||
}).toList();
|
}).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -267,16 +266,6 @@ 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,7 +8,6 @@ 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';
|
||||||
|
|
@ -19,7 +18,6 @@ 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
|
||||||
|
|
@ -33,22 +31,12 @@ 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.
|
||||||
|
|
@ -112,9 +100,6 @@ 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;
|
||||||
|
|
@ -228,8 +213,6 @@ 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),
|
||||||
|
|
@ -257,7 +240,6 @@ class _MarketScreenState extends State<MarketScreen> {
|
||||||
hasArea: _hasArea,
|
hasArea: _hasArea,
|
||||||
selfPubkey: widget.social.publicKeyHex,
|
selfPubkey: widget.social.publicKeyHex,
|
||||||
onOfferClosed: _reloadBlocked,
|
onOfferClosed: _reloadBlocked,
|
||||||
savedSearches: widget.savedSearches,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -271,15 +253,11 @@ 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;
|
||||||
|
|
||||||
|
|
@ -368,20 +346,6 @@ 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,
|
||||||
|
|
@ -441,63 +405,6 @@ 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 {
|
||||||
|
|
|
||||||
|
|
@ -1,282 +0,0 @@
|
||||||
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,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,148 +0,0 @@
|
||||||
import 'package:commons_core/commons_core.dart';
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
|
||||||
import 'package:tane/services/notification_service.dart';
|
|
||||||
import 'package:tane/services/saved_search_alert_service.dart';
|
|
||||||
import 'package:tane/services/saved_searches_store.dart';
|
|
||||||
import 'package:tane/services/social_connection.dart';
|
|
||||||
import 'package:tane/services/social_service.dart';
|
|
||||||
import 'package:tane/services/social_settings.dart';
|
|
||||||
|
|
||||||
import '../support/test_support.dart';
|
|
||||||
|
|
||||||
/// Records search-alert calls instead of touching the OS plugin.
|
|
||||||
class _RecordingNotifications extends NotificationService {
|
|
||||||
_RecordingNotifications() : super(supported: false);
|
|
||||||
|
|
||||||
final searchIds = <String>[];
|
|
||||||
final titles = <String>[];
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<void> showSearchAlert({
|
|
||||||
required String searchId,
|
|
||||||
required String title,
|
|
||||||
}) async {
|
|
||||||
searchIds.add(searchId);
|
|
||||||
titles.add(title);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
const seedHex =
|
|
||||||
'000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f';
|
|
||||||
const myPubkey = 'me';
|
|
||||||
|
|
||||||
// A connection that never actually opens — ingest doesn't use it.
|
|
||||||
Future<SocialConnection> offlineConnection() async => SocialConnection(
|
|
||||||
social: await SocialService.fromRootSeedHex(seedHex),
|
|
||||||
settings: SocialSettings(InMemorySecretStore()),
|
|
||||||
open: (_) async => throw StateError('unused in ingest tests'),
|
|
||||||
online: const Stream.empty(),
|
|
||||||
);
|
|
||||||
|
|
||||||
late SavedSearchesStore store;
|
|
||||||
late SocialSettings settings;
|
|
||||||
late _RecordingNotifications notifications;
|
|
||||||
late SavedSearchAlertService service;
|
|
||||||
|
|
||||||
setUp(() async {
|
|
||||||
store = SavedSearchesStore(InMemorySecretStore());
|
|
||||||
settings = SocialSettings(InMemorySecretStore());
|
|
||||||
notifications = _RecordingNotifications();
|
|
||||||
service = SavedSearchAlertService(
|
|
||||||
connection: await offlineConnection(),
|
|
||||||
selfPubkey: myPubkey,
|
|
||||||
store: store,
|
|
||||||
settings: settings,
|
|
||||||
notifications: notifications,
|
|
||||||
alertTitle: (s) => 'New match: ${s.label}',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
tearDown(() async {
|
|
||||||
await service.stop();
|
|
||||||
await store.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
Offer offer({
|
|
||||||
String id = 'o1',
|
|
||||||
String author = 'grower',
|
|
||||||
String summary = 'Heirloom tomato seeds',
|
|
||||||
OfferType type = OfferType.gift,
|
|
||||||
OfferLifecycle status = OfferLifecycle.active,
|
|
||||||
String? category = 'Solanaceae',
|
|
||||||
bool isOrganic = false,
|
|
||||||
}) =>
|
|
||||||
Offer(
|
|
||||||
id: id,
|
|
||||||
authorPubkeyHex: author,
|
|
||||||
summary: summary,
|
|
||||||
type: type,
|
|
||||||
status: status,
|
|
||||||
approxGeohash: 'u09',
|
|
||||||
category: category,
|
|
||||||
isOrganic: isOrganic,
|
|
||||||
);
|
|
||||||
|
|
||||||
SavedSearch search(String id, {String query = ''}) =>
|
|
||||||
SavedSearch(id: id, label: id, query: query);
|
|
||||||
|
|
||||||
test('a matching offer fires one alert and records the match', () async {
|
|
||||||
await store.save(search('tom', query: 'tomato'));
|
|
||||||
await service.ingest(offer());
|
|
||||||
expect(notifications.searchIds, ['tom']);
|
|
||||||
expect(notifications.titles, ['New match: tom']);
|
|
||||||
expect(await store.newMatchCount('tom'), 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a non-matching offer is silent', () async {
|
|
||||||
await store.save(search('pep', query: 'pepper'));
|
|
||||||
await service.ingest(offer());
|
|
||||||
expect(notifications.searchIds, isEmpty);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a replayed offer does not re-alert', () async {
|
|
||||||
await store.save(search('tom', query: 'tomato'));
|
|
||||||
await service.ingest(offer());
|
|
||||||
await service.ingest(offer()); // relay replays the stored event
|
|
||||||
expect(notifications.searchIds, ['tom']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('one offer can match several searches', () async {
|
|
||||||
await store.save(search('all'));
|
|
||||||
await store.save(search('tom', query: 'tomato'));
|
|
||||||
await service.ingest(offer());
|
|
||||||
expect(notifications.searchIds, containsAll(<String>['all', 'tom']));
|
|
||||||
expect(notifications.searchIds, hasLength(2));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('my own offers never alert', () async {
|
|
||||||
await store.save(search('all'));
|
|
||||||
await service.ingest(offer(author: myPubkey));
|
|
||||||
expect(notifications.searchIds, isEmpty);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('non-active offers never alert', () async {
|
|
||||||
await store.save(search('all'));
|
|
||||||
await service.ingest(offer(status: OfferLifecycle.closed));
|
|
||||||
expect(notifications.searchIds, isEmpty);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('offers from a blocked author never alert', () async {
|
|
||||||
await settings.block('grower');
|
|
||||||
await store.save(search('all'));
|
|
||||||
await service.ingest(offer());
|
|
||||||
expect(notifications.searchIds, isEmpty);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a hidden (reported) offer never alerts', () async {
|
|
||||||
await settings.hideOffer(authorPubkeyHex: 'grower', offerId: 'o1');
|
|
||||||
await store.save(search('all'));
|
|
||||||
await service.ingest(offer());
|
|
||||||
expect(notifications.searchIds, isEmpty);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('with no saved searches nothing fires', () async {
|
|
||||||
await service.ingest(offer());
|
|
||||||
expect(notifications.searchIds, isEmpty);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,97 +0,0 @@
|
||||||
import 'package:tane/services/saved_searches_store.dart';
|
|
||||||
import 'package:commons_core/commons_core.dart';
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
|
||||||
|
|
||||||
import '../support/test_support.dart';
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
late SavedSearchesStore store;
|
|
||||||
|
|
||||||
setUp(() => store = SavedSearchesStore(InMemorySecretStore()));
|
|
||||||
tearDown(() => store.close());
|
|
||||||
|
|
||||||
SavedSearch search(String id, {String query = '', int createdAt = 0}) =>
|
|
||||||
SavedSearch(id: id, label: id, query: query, createdAt: createdAt);
|
|
||||||
|
|
||||||
test('saves and lists newest first', () async {
|
|
||||||
await store.save(search('a', createdAt: 100));
|
|
||||||
await store.save(search('b', createdAt: 200));
|
|
||||||
final list = await store.list();
|
|
||||||
expect(list.map((s) => s.id), ['b', 'a']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('save is idempotent on id', () async {
|
|
||||||
await store.save(search('a', query: 'one'));
|
|
||||||
await store.save(search('a', query: 'two'));
|
|
||||||
final list = await store.list();
|
|
||||||
expect(list, hasLength(1));
|
|
||||||
expect(list.single.query, 'two');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('remove deletes a search', () async {
|
|
||||||
await store.save(search('a'));
|
|
||||||
await store.remove('a');
|
|
||||||
expect(await store.list(), isEmpty);
|
|
||||||
expect(await store.exists('a'), isFalse);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('markMatched reports a fresh match once, then dedups replays', () async {
|
|
||||||
await store.save(search('a'));
|
|
||||||
expect(await store.markMatched('a', 'author:o1'), isTrue);
|
|
||||||
// Same key again = replay/echo, not a new alert.
|
|
||||||
expect(await store.markMatched('a', 'author:o1'), isFalse);
|
|
||||||
expect(await store.markMatched('a', 'author:o2'), isTrue);
|
|
||||||
expect(await store.newMatchCount('a'), 2);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('markMatched returns false for an unknown search', () async {
|
|
||||||
expect(await store.markMatched('ghost', 'author:o1'), isFalse);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('seeded seen keys suppress alerts for already-visible offers', () async {
|
|
||||||
await store.save(search('a'), seedSeenKeys: ['author:o1']);
|
|
||||||
expect(await store.markMatched('a', 'author:o1'), isFalse);
|
|
||||||
expect(await store.newMatchCount('a'), 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('markViewed clears unread but keeps seen (no re-alert)', () async {
|
|
||||||
await store.save(search('a'));
|
|
||||||
await store.markMatched('a', 'author:o1');
|
|
||||||
await store.markViewed('a');
|
|
||||||
expect(await store.newMatchCount('a'), 0);
|
|
||||||
// Still seen: the same offer won't alert again.
|
|
||||||
expect(await store.markMatched('a', 'author:o1'), isFalse);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('totalNewMatchCount sums across searches', () async {
|
|
||||||
await store.save(search('a'));
|
|
||||||
await store.save(search('b'));
|
|
||||||
await store.markMatched('a', 'author:o1');
|
|
||||||
await store.markMatched('b', 'author:o2');
|
|
||||||
await store.markMatched('b', 'author:o3');
|
|
||||||
expect(await store.totalNewMatchCount(), 3);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('changes stream fires on mutations', () async {
|
|
||||||
final events = <void>[];
|
|
||||||
final sub = store.changes.listen(events.add);
|
|
||||||
await store.save(search('a'));
|
|
||||||
await store.markMatched('a', 'author:o1');
|
|
||||||
await store.markViewed('a');
|
|
||||||
await store.remove('a');
|
|
||||||
await Future<void>.delayed(Duration.zero);
|
|
||||||
expect(events, hasLength(4));
|
|
||||||
await sub.cancel();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('account scopes are isolated', () async {
|
|
||||||
final secret = InMemorySecretStore();
|
|
||||||
final s1 = SavedSearchesStore(secret, accountScope: 'acct1');
|
|
||||||
final s2 = SavedSearchesStore(secret, accountScope: 'acct2');
|
|
||||||
await s1.save(search('a'));
|
|
||||||
expect(await s1.list(), hasLength(1));
|
|
||||||
expect(await s2.list(), isEmpty);
|
|
||||||
await s1.close();
|
|
||||||
await s2.close();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,74 +0,0 @@
|
||||||
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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -35,7 +35,6 @@ export 'src/social/profile_transport.dart';
|
||||||
export 'src/social/rating.dart';
|
export 'src/social/rating.dart';
|
||||||
export 'src/social/rating_transport.dart';
|
export 'src/social/rating_transport.dart';
|
||||||
export 'src/social/report_transport.dart';
|
export 'src/social/report_transport.dart';
|
||||||
export 'src/social/saved_search.dart';
|
|
||||||
export 'src/social/sync_transport.dart';
|
export 'src/social/sync_transport.dart';
|
||||||
export 'src/social/trust_transport.dart';
|
export 'src/social/trust_transport.dart';
|
||||||
export 'src/social/web_of_trust.dart';
|
export 'src/social/web_of_trust.dart';
|
||||||
|
|
|
||||||
|
|
@ -1,124 +0,0 @@
|
||||||
import 'package:equatable/equatable.dart';
|
|
||||||
|
|
||||||
import 'offer.dart';
|
|
||||||
|
|
||||||
/// A person's saved market search — the query and facets they want to be
|
|
||||||
/// alerted about, Wallapop's "búsquedas favoritas". Generic core type: it knows
|
|
||||||
/// how to decide whether an [Offer] matches, but nothing about seeds.
|
|
||||||
///
|
|
||||||
/// The filter chain mirrors the market screen's live filtering exactly (same
|
|
||||||
/// case-insensitive summary match, same ANDed facets), so a saved search and
|
|
||||||
/// the on-screen results can never disagree. Moderation state (blocked authors,
|
|
||||||
/// hidden offers, the user's own listings) is deliberately NOT part of a search
|
|
||||||
/// — the app layer applies that on top.
|
|
||||||
class SavedSearch extends Equatable {
|
|
||||||
const SavedSearch({
|
|
||||||
required this.id,
|
|
||||||
required this.label,
|
|
||||||
this.query = '',
|
|
||||||
this.types = const {},
|
|
||||||
this.categories = const {},
|
|
||||||
this.organicOnly = false,
|
|
||||||
this.createdAt = 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
/// Stable per-search identifier (a UUID from the app's IdGen).
|
|
||||||
final String id;
|
|
||||||
|
|
||||||
/// Human name for the search, shown in the list (e.g. "Tomates cerca").
|
|
||||||
final String label;
|
|
||||||
|
|
||||||
/// Free-text filter over the offer summary; empty = no text constraint.
|
|
||||||
final String query;
|
|
||||||
|
|
||||||
/// Reciprocity-mode facet (gift/exchange/sale/wanted); empty = any type.
|
|
||||||
final Set<OfferType> types;
|
|
||||||
|
|
||||||
/// Category facet (free-text categories); empty = any category.
|
|
||||||
final Set<String> categories;
|
|
||||||
|
|
||||||
/// When true, only offers the grower declared organic ("eco") match.
|
|
||||||
final bool organicOnly;
|
|
||||||
|
|
||||||
/// Creation time (ms since epoch), for stable newest-first ordering.
|
|
||||||
final int createdAt;
|
|
||||||
|
|
||||||
/// Whether [offer] satisfies this search's query and facets (all ANDed).
|
|
||||||
/// Same semantics the market screen uses for its visible list.
|
|
||||||
bool matches(Offer offer) => matchesFilters(
|
|
||||||
offer,
|
|
||||||
query: query,
|
|
||||||
types: types,
|
|
||||||
categories: categories,
|
|
||||||
organicOnly: organicOnly,
|
|
||||||
);
|
|
||||||
|
|
||||||
/// The shared, pure filter chain used by both the market screen's live
|
|
||||||
/// results and saved-search alerts, so the two paths can't drift.
|
|
||||||
static bool matchesFilters(
|
|
||||||
Offer offer, {
|
|
||||||
String query = '',
|
|
||||||
Set<OfferType> types = const {},
|
|
||||||
Set<String> categories = const {},
|
|
||||||
bool organicOnly = false,
|
|
||||||
}) {
|
|
||||||
final q = query.trim().toLowerCase();
|
|
||||||
if (q.isNotEmpty && !offer.summary.toLowerCase().contains(q)) return false;
|
|
||||||
if (types.isNotEmpty && !types.contains(offer.type)) return false;
|
|
||||||
if (categories.isNotEmpty &&
|
|
||||||
(offer.category == null || !categories.contains(offer.category))) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (organicOnly && !offer.isOrganic) return false;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
SavedSearch copyWith({
|
|
||||||
String? id,
|
|
||||||
String? label,
|
|
||||||
String? query,
|
|
||||||
Set<OfferType>? types,
|
|
||||||
Set<String>? categories,
|
|
||||||
bool? organicOnly,
|
|
||||||
int? createdAt,
|
|
||||||
}) {
|
|
||||||
return SavedSearch(
|
|
||||||
id: id ?? this.id,
|
|
||||||
label: label ?? this.label,
|
|
||||||
query: query ?? this.query,
|
|
||||||
types: types ?? this.types,
|
|
||||||
categories: categories ?? this.categories,
|
|
||||||
organicOnly: organicOnly ?? this.organicOnly,
|
|
||||||
createdAt: createdAt ?? this.createdAt,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, dynamic> toJson() => {
|
|
||||||
'id': id,
|
|
||||||
'label': label,
|
|
||||||
if (query.isNotEmpty) 'query': query,
|
|
||||||
if (types.isNotEmpty) 'types': [for (final t in types) t.name],
|
|
||||||
if (categories.isNotEmpty) 'categories': categories.toList(),
|
|
||||||
if (organicOnly) 'organicOnly': true,
|
|
||||||
'createdAt': createdAt,
|
|
||||||
};
|
|
||||||
|
|
||||||
factory SavedSearch.fromJson(Map<String, dynamic> json) => SavedSearch(
|
|
||||||
id: json['id'] as String,
|
|
||||||
label: json['label'] as String,
|
|
||||||
query: json['query'] as String? ?? '',
|
|
||||||
types: {
|
|
||||||
for (final t in (json['types'] as List? ?? const []))
|
|
||||||
OfferType.values.byName(t as String),
|
|
||||||
},
|
|
||||||
categories: {
|
|
||||||
for (final c in (json['categories'] as List? ?? const [])) c as String,
|
|
||||||
},
|
|
||||||
organicOnly: json['organicOnly'] as bool? ?? false,
|
|
||||||
createdAt: json['createdAt'] as int? ?? 0,
|
|
||||||
);
|
|
||||||
|
|
||||||
@override
|
|
||||||
List<Object?> get props =>
|
|
||||||
[id, label, query, types, categories, organicOnly, createdAt];
|
|
||||||
}
|
|
||||||
|
|
@ -1,116 +0,0 @@
|
||||||
import 'package:commons_core/commons_core.dart';
|
|
||||||
import 'package:test/test.dart';
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
Offer offer({
|
|
||||||
String summary = 'Heirloom tomato seeds',
|
|
||||||
OfferType type = OfferType.gift,
|
|
||||||
String? category = 'Solanaceae',
|
|
||||||
bool isOrganic = false,
|
|
||||||
}) =>
|
|
||||||
Offer(
|
|
||||||
id: 'o1',
|
|
||||||
authorPubkeyHex: 'author1',
|
|
||||||
summary: summary,
|
|
||||||
type: type,
|
|
||||||
approxGeohash: 'u09',
|
|
||||||
category: category,
|
|
||||||
isOrganic: isOrganic,
|
|
||||||
);
|
|
||||||
|
|
||||||
SavedSearch search({
|
|
||||||
String query = '',
|
|
||||||
Set<OfferType> types = const {},
|
|
||||||
Set<String> categories = const {},
|
|
||||||
bool organicOnly = false,
|
|
||||||
}) =>
|
|
||||||
SavedSearch(
|
|
||||||
id: 's1',
|
|
||||||
label: 'test',
|
|
||||||
query: query,
|
|
||||||
types: types,
|
|
||||||
categories: categories,
|
|
||||||
organicOnly: organicOnly,
|
|
||||||
);
|
|
||||||
|
|
||||||
group('matches', () {
|
|
||||||
test('empty search matches any active offer', () {
|
|
||||||
expect(search().matches(offer()), isTrue);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('text query matches case-insensitively over the summary', () {
|
|
||||||
expect(search(query: 'TOMATO').matches(offer()), isTrue);
|
|
||||||
expect(search(query: ' tomato ').matches(offer()), isTrue);
|
|
||||||
expect(search(query: 'pepper').matches(offer()), isFalse);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('type facet filters by reciprocity mode', () {
|
|
||||||
expect(search(types: {OfferType.gift}).matches(offer()), isTrue);
|
|
||||||
expect(search(types: {OfferType.sale}).matches(offer()), isFalse);
|
|
||||||
expect(
|
|
||||||
search(types: {OfferType.gift, OfferType.exchange}).matches(offer()),
|
|
||||||
isTrue,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('category facet requires a matching category', () {
|
|
||||||
expect(search(categories: {'Solanaceae'}).matches(offer()), isTrue);
|
|
||||||
expect(search(categories: {'Fabaceae'}).matches(offer()), isFalse);
|
|
||||||
expect(
|
|
||||||
search(categories: {'Solanaceae'}).matches(offer(category: null)),
|
|
||||||
isFalse,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('organicOnly requires a self-declared organic offer', () {
|
|
||||||
expect(search(organicOnly: true).matches(offer(isOrganic: true)), isTrue);
|
|
||||||
expect(search(organicOnly: true).matches(offer(isOrganic: false)), isFalse);
|
|
||||||
expect(search().matches(offer(isOrganic: false)), isTrue);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('facets are ANDed together', () {
|
|
||||||
final s = search(
|
|
||||||
query: 'tomato',
|
|
||||||
types: {OfferType.gift},
|
|
||||||
categories: {'Solanaceae'},
|
|
||||||
organicOnly: true,
|
|
||||||
);
|
|
||||||
expect(s.matches(offer(isOrganic: true)), isTrue);
|
|
||||||
// One facet off is enough to reject.
|
|
||||||
expect(s.matches(offer(isOrganic: false)), isFalse);
|
|
||||||
expect(s.matches(offer(type: OfferType.sale, isOrganic: true)), isFalse);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('matchesFilters agrees with the instance matcher', () {
|
|
||||||
expect(
|
|
||||||
SavedSearch.matchesFilters(offer(), query: 'tomato'),
|
|
||||||
search(query: 'tomato').matches(offer()),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
group('json', () {
|
|
||||||
test('round-trips all fields', () {
|
|
||||||
final s = SavedSearch(
|
|
||||||
id: 's1',
|
|
||||||
label: 'Tomates cerca',
|
|
||||||
query: 'tomate',
|
|
||||||
types: {OfferType.gift, OfferType.exchange},
|
|
||||||
categories: {'Solanaceae', 'Fabaceae'},
|
|
||||||
organicOnly: true,
|
|
||||||
createdAt: 1720000000000,
|
|
||||||
);
|
|
||||||
expect(SavedSearch.fromJson(s.toJson()), s);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('round-trips a minimal search (empty facets)', () {
|
|
||||||
final s = SavedSearch(id: 's2', label: 'all', createdAt: 42);
|
|
||||||
final decoded = SavedSearch.fromJson(s.toJson());
|
|
||||||
expect(decoded, s);
|
|
||||||
expect(decoded.query, isEmpty);
|
|
||||||
expect(decoded.types, isEmpty);
|
|
||||||
expect(decoded.categories, isEmpty);
|
|
||||||
expect(decoded.organicOnly, isFalse);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue