diff --git a/app/lib/features/catalog/presentation/search/catalog_search_controller.dart b/app/lib/features/catalog/presentation/search/catalog_search_controller.dart index 4b6b78a..3603ed4 100644 --- a/app/lib/features/catalog/presentation/search/catalog_search_controller.dart +++ b/app/lib/features/catalog/presentation/search/catalog_search_controller.dart @@ -66,6 +66,25 @@ class CatalogSearchController extends _$CatalogSearchController { unawaited(_search()); } + /// Drops genre IDs (from the URL) that are not in the vocabulary [known]. + /// Searches again only if something was removed. + void retainKnownGenres(Set known) { + final filters = state.query.filters; + final kept = filters.genreIds.where(known.contains).toSet(); + if (kept.length != filters.genreIds.length) { + setFilters(filters.copyWith(genreIds: kept)); + } + } + + /// Like [retainKnownGenres] for mechanics. + void retainKnownMechanics(Set known) { + final filters = state.query.filters; + final kept = filters.mechanicIds.where(known.contains).toSet(); + if (kept.length != filters.mechanicIds.length) { + setFilters(filters.copyWith(mechanicIds: kept)); + } + } + void clearFilters() => setFilters(const CatalogFilters()); /// Loads the next page of the shown results. diff --git a/app/lib/features/catalog/presentation/search/catalog_search_controller.g.dart b/app/lib/features/catalog/presentation/search/catalog_search_controller.g.dart index 692cead..47d96f7 100644 --- a/app/lib/features/catalog/presentation/search/catalog_search_controller.g.dart +++ b/app/lib/features/catalog/presentation/search/catalog_search_controller.g.dart @@ -48,7 +48,7 @@ final class CatalogSearchControllerProvider } String _$catalogSearchControllerHash() => - r'b59678c9b9948ebe8bbfedce7faa428ffff34af1'; + r'e75dae20e21a2595e02dd79004172f34c90ba90c'; /// Logic of the catalog search: debounce, only the latest request counts, /// paging, retry (KAT-01 criteria 1, 4, 5, 8). diff --git a/app/lib/features/catalog/presentation/search/catalog_search_view.dart b/app/lib/features/catalog/presentation/search/catalog_search_view.dart index 62c1d76..b48fa86 100644 --- a/app/lib/features/catalog/presentation/search/catalog_search_view.dart +++ b/app/lib/features/catalog/presentation/search/catalog_search_view.dart @@ -12,7 +12,9 @@ import 'package:shuffle/core/widgets/app_list_footer.dart'; import 'package:shuffle/core/widgets/app_state_message.dart'; import 'package:shuffle/features/catalog/domain/catalog_failure.dart'; import 'package:shuffle/features/catalog/domain/catalog_search_query.dart'; +import 'package:shuffle/features/catalog/domain/catalog_term.dart'; import 'package:shuffle/features/catalog/presentation/catalog_query_params.dart'; +import 'package:shuffle/features/catalog/presentation/providers/catalog_providers.dart'; import 'package:shuffle/features/catalog/presentation/search/catalog_search_controller.dart'; import 'package:shuffle/features/catalog/presentation/search/catalog_search_state.dart'; import 'package:shuffle/features/catalog/presentation/search/widgets/catalog_empty_state.dart'; @@ -65,6 +67,7 @@ class _CatalogSearchViewState extends ConsumerState { final ScrollController _scroll = ScrollController(); final FocusNode _newTileFocus = FocusNode(); Timer? _announceTimer; + final List> _termSubscriptions = []; /// Index of the first tile that came with "load more"; it takes focus. int? _focusIndex; @@ -78,13 +81,54 @@ class _CatalogSearchViewState extends ConsumerState { ref .read(catalogSearchControllerProvider.notifier) .init(widget.initialQuery); + _pruneUnknownTerms(); } }); } + /// IDs from the URL that the vocabulary does not know are dropped once it is + /// loaded (the search then runs again, the URL follows). A load error + /// removes nothing. + void _pruneUnknownTerms() { + final filters = widget.initialQuery.filters; + if (filters.genreIds.isNotEmpty) { + _termSubscriptions.add( + ref.listenManual>>(catalogGenresProvider, ( + AsyncValue>? previous, + AsyncValue> next, + ) { + final terms = next.value; + if (terms != null) { + _controller.retainKnownGenres({for (final t in terms) t.id}); + } + }, fireImmediately: true), + ); + } + if (filters.mechanicIds.isNotEmpty) { + _termSubscriptions.add( + ref.listenManual>>( + catalogMechanicsProvider, + ( + AsyncValue>? previous, + AsyncValue> next, + ) { + final terms = next.value; + if (terms != null) { + _controller.retainKnownMechanics({for (final t in terms) t.id}); + } + }, + fireImmediately: true, + ), + ); + } + } + @override void dispose() { _announceTimer?.cancel(); + for (final subscription in _termSubscriptions) { + subscription.close(); + } _scroll.dispose(); _newTileFocus.dispose(); super.dispose(); diff --git a/app/lib/features/catalog/presentation/widgets/catalog_search_entry_button.dart b/app/lib/features/catalog/presentation/widgets/catalog_search_entry_button.dart index e241a40..7220958 100644 --- a/app/lib/features/catalog/presentation/widgets/catalog_search_entry_button.dart +++ b/app/lib/features/catalog/presentation/widgets/catalog_search_entry_button.dart @@ -10,11 +10,12 @@ class CatalogSearchEntryButton extends StatelessWidget { @override Widget build(BuildContext context) { - return IconButton( + final label = context.l10n.catalogSearchTitle; + return FilledButton.icon( key: const Key('home_catalog_search_button'), - tooltip: context.l10n.catalogSearchTitle, - icon: const Icon(Icons.search), onPressed: () => context.go(AppRoutes.catalog), + icon: const Icon(Icons.search), + label: Text(label), ); } } diff --git a/app/lib/features/home/presentation/home_screen.dart b/app/lib/features/home/presentation/home_screen.dart index e26c4a9..c194916 100644 --- a/app/lib/features/home/presentation/home_screen.dart +++ b/app/lib/features/home/presentation/home_screen.dart @@ -11,17 +11,21 @@ class HomeScreen extends StatelessWidget { final l10n = context.l10n; return Scaffold( key: const Key('home_screen'), - appBar: AppBar( - title: Text(l10n.appTitle), - actions: const [CatalogSearchEntryButton()], - ), + appBar: AppBar(title: Text(l10n.appTitle)), body: Center( child: Padding( padding: const EdgeInsets.all(24), - child: Text( - l10n.homePlaceholderMessage, - key: const Key('home_placeholder_message'), - textAlign: TextAlign.center, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + l10n.homePlaceholderMessage, + key: const Key('home_placeholder_message'), + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + const CatalogSearchEntryButton(), + ], ), ), ), diff --git a/app/test/features/catalog/presentation/search/catalog_search_screen_test.dart b/app/test/features/catalog/presentation/search/catalog_search_screen_test.dart index 331c5f1..5038239 100644 --- a/app/test/features/catalog/presentation/search/catalog_search_screen_test.dart +++ b/app/test/features/catalog/presentation/search/catalog_search_screen_test.dart @@ -7,6 +7,7 @@ import 'package:shuffle/core/routing/url_replacer.dart'; import 'package:shuffle/features/auth/domain/auth_status.dart'; import 'package:shuffle/features/auth/domain/auth_user.dart'; import 'package:shuffle/features/auth/presentation/providers/auth_redirect.dart'; +import 'package:shuffle/features/catalog/domain/catalog_failure.dart'; import 'package:shuffle/features/catalog/presentation/catalog_routes.dart'; import 'package:shuffle/features/catalog/presentation/search/catalog_search_view.dart'; @@ -88,6 +89,13 @@ void main() { final repository = FakeCatalogRepository(all: [game(1)]); await pumpShuffleApp(tester, overrides: catalogOverrides(repository)); + expect(find.text('Search games'), findsOneWidget); + expect( + tester + .getSize(find.byKey(const Key('home_catalog_search_button'))) + .height, + greaterThanOrEqualTo(48), + ); await tester.tap(find.byKey(const Key('home_catalog_search_button'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('catalog_search_screen')), findsOneWidget); @@ -108,6 +116,7 @@ void main() { WidgetTester tester, FakeCatalogRepository repository, { required bool isWeb, + String initialLocation = '/catalog?q=alt', }) async { useWindow(tester, 420); tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( @@ -125,7 +134,7 @@ void main() { ); final router = buildTestRouter( routes: catalogRoutes, - initialLocation: '/catalog?q=alt', + initialLocation: initialLocation, ); addTearDown(router.dispose); await pumpApp( @@ -191,6 +200,82 @@ void main() { expect(find.byKey(const Key('catalog_filter_sheet')), findsOneWidget); }); + const unknownGenre = '99999999-9999-4999-8999-999999999999'; + const unknownMechanic = '88888888-8888-4888-8888-888888888888'; + + testWidgets('unknown IDs from the URL are removed once the vocabulary is ' + 'loaded; valid ones stay, the search runs once more', (tester) async { + final repository = FakeCatalogRepository(all: [game(1)]); + final router = await pumpRouted( + tester, + repository, + isWeb: true, + initialLocation: + '/catalog?genre=$genreStrategy,$unknownGenre' + '&mechanic=$unknownMechanic,$mechanicDice', + ); + + final last = repository.calls.last.query.filters; + expect(last.genreIds, {genreStrategy}); + expect(last.mechanicIds, {mechanicDice}); + // First search with the raw URL values, then the cleaned one(s); no loop. + expect(repository.calls.length, lessThanOrEqualTo(3)); + final before = repository.calls.length; + await tester.pump(const Duration(seconds: 2)); + expect(repository.calls.length, before); + // The counter shows what is really active and the URL follows. + expect(find.text('Filter (2)'), findsOneWidget); + final params = currentUri(router).queryParameters; + expect(params['genre'], genreStrategy); + expect(params['mechanic'], mechanicDice); + }); + + testWidgets('a single unknown ID triggers exactly one extra search', ( + tester, + ) async { + final repository = FakeCatalogRepository(all: [game(1)]); + await pumpRouted( + tester, + repository, + isWeb: true, + initialLocation: '/catalog?genre=$genreStrategy,$unknownGenre', + ); + + expect(repository.calls, hasLength(2)); + expect(repository.calls.last.query.filters.genreIds, {genreStrategy}); + }); + + testWidgets('only known IDs: nothing is removed, no extra search', ( + tester, + ) async { + final repository = FakeCatalogRepository(all: [game(1)]); + await pumpRouted( + tester, + repository, + isWeb: true, + initialLocation: '/catalog?genre=$genreStrategy', + ); + + expect(repository.calls, hasLength(1)); + }); + + testWidgets('a vocabulary load error removes nothing', (tester) async { + final repository = FakeCatalogRepository(all: [game(1)]) + ..termsFailWith = const CatalogFailure.network(); + await pumpRouted( + tester, + repository, + isWeb: true, + initialLocation: '/catalog?genre=$genreStrategy,$unknownGenre', + ); + + expect(repository.calls, hasLength(1)); + expect(repository.calls.last.query.filters.genreIds, { + genreStrategy, + unknownGenre, + }); + }); + testWidgets('outside the web nothing is written', (tester) async { final repository = FakeCatalogRepository(all: [game(1)]); final router = await pumpRouted(tester, repository, isWeb: false);