feat(catalog): labeled start page button, prune unknown term IDs from the URL

Refs #3

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
cschlaefke 2026-11-04 09:59:54 +01:00
parent 2d18a49fc2
commit ae099eb3d7
6 changed files with 166 additions and 13 deletions

View file

@ -66,6 +66,25 @@ class CatalogSearchController extends _$CatalogSearchController {
unawaited(_search()); 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<String> 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<String> 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()); void clearFilters() => setFilters(const CatalogFilters());
/// Loads the next page of the shown results. /// Loads the next page of the shown results.

View file

@ -48,7 +48,7 @@ final class CatalogSearchControllerProvider
} }
String _$catalogSearchControllerHash() => String _$catalogSearchControllerHash() =>
r'b59678c9b9948ebe8bbfedce7faa428ffff34af1'; r'e75dae20e21a2595e02dd79004172f34c90ba90c';
/// Logic of the catalog search: debounce, only the latest request counts, /// Logic of the catalog search: debounce, only the latest request counts,
/// paging, retry (KAT-01 criteria 1, 4, 5, 8). /// paging, retry (KAT-01 criteria 1, 4, 5, 8).

View file

@ -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/core/widgets/app_state_message.dart';
import 'package:shuffle/features/catalog/domain/catalog_failure.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_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/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_controller.dart';
import 'package:shuffle/features/catalog/presentation/search/catalog_search_state.dart'; import 'package:shuffle/features/catalog/presentation/search/catalog_search_state.dart';
import 'package:shuffle/features/catalog/presentation/search/widgets/catalog_empty_state.dart'; import 'package:shuffle/features/catalog/presentation/search/widgets/catalog_empty_state.dart';
@ -65,6 +67,7 @@ class _CatalogSearchViewState extends ConsumerState<CatalogSearchView> {
final ScrollController _scroll = ScrollController(); final ScrollController _scroll = ScrollController();
final FocusNode _newTileFocus = FocusNode(); final FocusNode _newTileFocus = FocusNode();
Timer? _announceTimer; Timer? _announceTimer;
final List<ProviderSubscription<Object?>> _termSubscriptions = [];
/// Index of the first tile that came with "load more"; it takes focus. /// Index of the first tile that came with "load more"; it takes focus.
int? _focusIndex; int? _focusIndex;
@ -78,13 +81,54 @@ class _CatalogSearchViewState extends ConsumerState<CatalogSearchView> {
ref ref
.read(catalogSearchControllerProvider.notifier) .read(catalogSearchControllerProvider.notifier)
.init(widget.initialQuery); .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<AsyncValue<List<CatalogTerm>>>(catalogGenresProvider, (
AsyncValue<List<CatalogTerm>>? previous,
AsyncValue<List<CatalogTerm>> 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<AsyncValue<List<CatalogTerm>>>(
catalogMechanicsProvider,
(
AsyncValue<List<CatalogTerm>>? previous,
AsyncValue<List<CatalogTerm>> next,
) {
final terms = next.value;
if (terms != null) {
_controller.retainKnownMechanics({for (final t in terms) t.id});
}
},
fireImmediately: true,
),
);
}
}
@override @override
void dispose() { void dispose() {
_announceTimer?.cancel(); _announceTimer?.cancel();
for (final subscription in _termSubscriptions) {
subscription.close();
}
_scroll.dispose(); _scroll.dispose();
_newTileFocus.dispose(); _newTileFocus.dispose();
super.dispose(); super.dispose();

View file

@ -10,11 +10,12 @@ class CatalogSearchEntryButton extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return IconButton( final label = context.l10n.catalogSearchTitle;
return FilledButton.icon(
key: const Key('home_catalog_search_button'), key: const Key('home_catalog_search_button'),
tooltip: context.l10n.catalogSearchTitle,
icon: const Icon(Icons.search),
onPressed: () => context.go(AppRoutes.catalog), onPressed: () => context.go(AppRoutes.catalog),
icon: const Icon(Icons.search),
label: Text(label),
); );
} }
} }

View file

@ -11,17 +11,21 @@ class HomeScreen extends StatelessWidget {
final l10n = context.l10n; final l10n = context.l10n;
return Scaffold( return Scaffold(
key: const Key('home_screen'), key: const Key('home_screen'),
appBar: AppBar( appBar: AppBar(title: Text(l10n.appTitle)),
title: Text(l10n.appTitle),
actions: const [CatalogSearchEntryButton()],
),
body: Center( body: Center(
child: Padding( child: Padding(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
child: Text( child: Column(
l10n.homePlaceholderMessage, mainAxisSize: MainAxisSize.min,
key: const Key('home_placeholder_message'), children: [
textAlign: TextAlign.center, Text(
l10n.homePlaceholderMessage,
key: const Key('home_placeholder_message'),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
const CatalogSearchEntryButton(),
],
), ),
), ),
), ),

View file

@ -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_status.dart';
import 'package:shuffle/features/auth/domain/auth_user.dart'; import 'package:shuffle/features/auth/domain/auth_user.dart';
import 'package:shuffle/features/auth/presentation/providers/auth_redirect.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/catalog_routes.dart';
import 'package:shuffle/features/catalog/presentation/search/catalog_search_view.dart'; import 'package:shuffle/features/catalog/presentation/search/catalog_search_view.dart';
@ -88,6 +89,13 @@ void main() {
final repository = FakeCatalogRepository(all: [game(1)]); final repository = FakeCatalogRepository(all: [game(1)]);
await pumpShuffleApp(tester, overrides: catalogOverrides(repository)); 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.tap(find.byKey(const Key('home_catalog_search_button')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byKey(const Key('catalog_search_screen')), findsOneWidget); expect(find.byKey(const Key('catalog_search_screen')), findsOneWidget);
@ -108,6 +116,7 @@ void main() {
WidgetTester tester, WidgetTester tester,
FakeCatalogRepository repository, { FakeCatalogRepository repository, {
required bool isWeb, required bool isWeb,
String initialLocation = '/catalog?q=alt',
}) async { }) async {
useWindow(tester, 420); useWindow(tester, 420);
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
@ -125,7 +134,7 @@ void main() {
); );
final router = buildTestRouter( final router = buildTestRouter(
routes: catalogRoutes, routes: catalogRoutes,
initialLocation: '/catalog?q=alt', initialLocation: initialLocation,
); );
addTearDown(router.dispose); addTearDown(router.dispose);
await pumpApp( await pumpApp(
@ -191,6 +200,82 @@ void main() {
expect(find.byKey(const Key('catalog_filter_sheet')), findsOneWidget); 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 { testWidgets('outside the web nothing is written', (tester) async {
final repository = FakeCatalogRepository(all: [game(1)]); final repository = FakeCatalogRepository(all: [game(1)]);
final router = await pumpRouted(tester, repository, isWeb: false); final router = await pumpRouted(tester, repository, isWeb: false);