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

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

View file

@ -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);
});
}