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:
parent
2d18a49fc2
commit
ae099eb3d7
6 changed files with 166 additions and 13 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,18 +11,22 @@ 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(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
l10n.homePlaceholderMessage,
|
l10n.homePlaceholderMessage,
|
||||||
key: const Key('home_placeholder_message'),
|
key: const Key('home_placeholder_message'),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
const CatalogSearchEntryButton(),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue