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());
|
||||
}
|
||||
|
||||
/// 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());
|
||||
|
||||
/// Loads the next page of the shown results.
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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<CatalogSearchView> {
|
|||
final ScrollController _scroll = ScrollController();
|
||||
final FocusNode _newTileFocus = FocusNode();
|
||||
Timer? _announceTimer;
|
||||
final List<ProviderSubscription<Object?>> _termSubscriptions = [];
|
||||
|
||||
/// Index of the first tile that came with "load more"; it takes focus.
|
||||
int? _focusIndex;
|
||||
|
|
@ -78,13 +81,54 @@ class _CatalogSearchViewState extends ConsumerState<CatalogSearchView> {
|
|||
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<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
|
||||
void dispose() {
|
||||
_announceTimer?.cancel();
|
||||
for (final subscription in _termSubscriptions) {
|
||||
subscription.close();
|
||||
}
|
||||
_scroll.dispose();
|
||||
_newTileFocus.dispose();
|
||||
super.dispose();
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,18 +11,22 @@ 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(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
l10n.homePlaceholderMessage,
|
||||
key: const Key('home_placeholder_message'),
|
||||
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_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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue