From 0775152870ade8d40c3306e9a642da6d81508eac Mon Sep 17 00:00:00 2001 From: cschlaefke Date: Wed, 4 Nov 2026 01:08:51 +0100 Subject: [PATCH 1/8] feat(catalog): catalog search feature with domain, data layer, controller and screen Refs #3 Co-Authored-By: Claude Sonnet 5.5 --- app/lib/core/routing/app_router.dart | 2 + app/lib/core/routing/app_router.g.dart | 2 +- app/lib/core/routing/app_routes.dart | 3 + app/lib/core/routing/url_replacer.dart | 41 ++ app/lib/core/routing/url_replacer.g.dart | 55 ++ app/lib/core/widgets/app_list_footer.dart | 94 +++ app/lib/core/widgets/app_state_message.dart | 100 +++ .../catalog/data/catalog_failure_mapper.dart | 20 + .../data/catalog_game_summary_dto.dart | 57 ++ .../data/catalog_game_summary_dto.g.dart | 27 + .../catalog/data/catalog_term_dto.dart | 23 + .../catalog/data/catalog_term_dto.g.dart | 14 + .../data/supabase_catalog_repository.dart | 113 ++++ .../catalog/domain/catalog_failure.dart | 16 + .../domain/catalog_failure.freezed.dart | 277 ++++++++ .../catalog/domain/catalog_game_summary.dart | 30 + .../domain/catalog_game_summary.freezed.dart | 321 ++++++++++ .../catalog/domain/catalog_repository.dart | 19 + .../catalog/domain/catalog_search_page.dart | 13 + .../domain/catalog_search_page.freezed.dart | 288 +++++++++ .../catalog/domain/catalog_search_query.dart | 101 +++ .../domain/catalog_search_query.freezed.dart | 602 ++++++++++++++++++ .../features/catalog/domain/catalog_term.dart | 20 + .../catalog/domain/catalog_term.freezed.dart | 285 +++++++++ .../presentation/catalog_query_params.dart | 90 +++ .../catalog/presentation/catalog_routes.dart | 14 + .../providers/catalog_providers.dart | 28 + .../providers/catalog_providers.g.dart | 152 +++++ .../search/catalog_search_controller.dart | 171 +++++ .../search/catalog_search_controller.g.dart | 72 +++ .../search/catalog_search_screen.dart | 40 ++ .../search/catalog_search_state.dart | 38 ++ .../search/catalog_search_state.freezed.dart | 406 ++++++++++++ .../search/catalog_search_view.dart | 458 +++++++++++++ .../search/widgets/catalog_empty_state.dart | 66 ++ .../search/widgets/catalog_filter_panel.dart | 236 +++++++ .../search/widgets/catalog_game_tile.dart | 180 ++++++ .../search/widgets/catalog_search_field.dart | 83 +++ .../widgets/catalog_search_entry_button.dart | 20 + .../home/presentation/home_screen.dart | 6 +- app/lib/l10n/app_de.arb | 38 +- app/lib/l10n/app_en.arb | 74 ++- app/lib/l10n/app_localizations.dart | 216 +++++++ app/lib/l10n/app_localizations_de.dart | 165 +++++ app/lib/l10n/app_localizations_en.dart | 162 +++++ .../catalog/catalog_test_support.dart | 151 +++++ .../supabase_catalog_repository_test.dart | 316 +++++++++ .../catalog/domain/catalog_domain_test.dart | 163 +++++ .../catalog_query_params_test.dart | 77 +++ .../catalog_search_controller_test.dart | 401 ++++++++++++ .../search/catalog_search_view_test.dart | 420 ++++++++++++ 51 files changed, 6762 insertions(+), 4 deletions(-) create mode 100644 app/lib/core/routing/url_replacer.dart create mode 100644 app/lib/core/routing/url_replacer.g.dart create mode 100644 app/lib/core/widgets/app_list_footer.dart create mode 100644 app/lib/core/widgets/app_state_message.dart create mode 100644 app/lib/features/catalog/data/catalog_failure_mapper.dart create mode 100644 app/lib/features/catalog/data/catalog_game_summary_dto.dart create mode 100644 app/lib/features/catalog/data/catalog_game_summary_dto.g.dart create mode 100644 app/lib/features/catalog/data/catalog_term_dto.dart create mode 100644 app/lib/features/catalog/data/catalog_term_dto.g.dart create mode 100644 app/lib/features/catalog/data/supabase_catalog_repository.dart create mode 100644 app/lib/features/catalog/domain/catalog_failure.dart create mode 100644 app/lib/features/catalog/domain/catalog_failure.freezed.dart create mode 100644 app/lib/features/catalog/domain/catalog_game_summary.dart create mode 100644 app/lib/features/catalog/domain/catalog_game_summary.freezed.dart create mode 100644 app/lib/features/catalog/domain/catalog_repository.dart create mode 100644 app/lib/features/catalog/domain/catalog_search_page.dart create mode 100644 app/lib/features/catalog/domain/catalog_search_page.freezed.dart create mode 100644 app/lib/features/catalog/domain/catalog_search_query.dart create mode 100644 app/lib/features/catalog/domain/catalog_search_query.freezed.dart create mode 100644 app/lib/features/catalog/domain/catalog_term.dart create mode 100644 app/lib/features/catalog/domain/catalog_term.freezed.dart create mode 100644 app/lib/features/catalog/presentation/catalog_query_params.dart create mode 100644 app/lib/features/catalog/presentation/catalog_routes.dart create mode 100644 app/lib/features/catalog/presentation/providers/catalog_providers.dart create mode 100644 app/lib/features/catalog/presentation/providers/catalog_providers.g.dart create mode 100644 app/lib/features/catalog/presentation/search/catalog_search_controller.dart create mode 100644 app/lib/features/catalog/presentation/search/catalog_search_controller.g.dart create mode 100644 app/lib/features/catalog/presentation/search/catalog_search_screen.dart create mode 100644 app/lib/features/catalog/presentation/search/catalog_search_state.dart create mode 100644 app/lib/features/catalog/presentation/search/catalog_search_state.freezed.dart create mode 100644 app/lib/features/catalog/presentation/search/catalog_search_view.dart create mode 100644 app/lib/features/catalog/presentation/search/widgets/catalog_empty_state.dart create mode 100644 app/lib/features/catalog/presentation/search/widgets/catalog_filter_panel.dart create mode 100644 app/lib/features/catalog/presentation/search/widgets/catalog_game_tile.dart create mode 100644 app/lib/features/catalog/presentation/search/widgets/catalog_search_field.dart create mode 100644 app/lib/features/catalog/presentation/widgets/catalog_search_entry_button.dart create mode 100644 app/test/features/catalog/catalog_test_support.dart create mode 100644 app/test/features/catalog/data/supabase_catalog_repository_test.dart create mode 100644 app/test/features/catalog/domain/catalog_domain_test.dart create mode 100644 app/test/features/catalog/presentation/catalog_query_params_test.dart create mode 100644 app/test/features/catalog/presentation/search/catalog_search_controller_test.dart create mode 100644 app/test/features/catalog/presentation/search/catalog_search_view_test.dart diff --git a/app/lib/core/routing/app_router.dart b/app/lib/core/routing/app_router.dart index 1d279ed..63baec9 100644 --- a/app/lib/core/routing/app_router.dart +++ b/app/lib/core/routing/app_router.dart @@ -5,6 +5,7 @@ import 'package:shuffle/core/routing/not_found_screen.dart'; import 'package:shuffle/core/routing/redirect_source.dart'; import 'package:shuffle/core/routing/volatile_extra_codec.dart'; import 'package:shuffle/features/auth/presentation/auth_routes.dart'; +import 'package:shuffle/features/catalog/presentation/catalog_routes.dart'; import 'package:shuffle/features/home/presentation/home_screen.dart'; part 'app_router.g.dart'; @@ -25,6 +26,7 @@ GoRouter appRouter(Ref ref) { builder: (context, state) => const HomeScreen(), ), ...authRoutes, + ...catalogRoutes, ], ); ref.onDispose(router.dispose); diff --git a/app/lib/core/routing/app_router.g.dart b/app/lib/core/routing/app_router.g.dart index 34d5139..13b150b 100644 --- a/app/lib/core/routing/app_router.g.dart +++ b/app/lib/core/routing/app_router.g.dart @@ -48,4 +48,4 @@ final class AppRouterProvider } } -String _$appRouterHash() => r'39ea53d71fba02f9e9cc5e42df61144e55eca3d9'; +String _$appRouterHash() => r'e5b9cf23cd96b28b8941552d226d592a1e06efc1'; diff --git a/app/lib/core/routing/app_routes.dart b/app/lib/core/routing/app_routes.dart index 64831b6..3ce97d5 100644 --- a/app/lib/core/routing/app_routes.dart +++ b/app/lib/core/routing/app_routes.dart @@ -2,6 +2,9 @@ abstract final class AppRoutes { static const home = '/'; + /// Katalogsuche, öffentlich (Spec #3 § 5.5). + static const catalog = '/catalog'; + // Auth (Spec #1 § 5.3) static const login = '/login'; static const register = '/register'; diff --git a/app/lib/core/routing/url_replacer.dart b/app/lib/core/routing/url_replacer.dart new file mode 100644 index 0000000..4cf5f49 --- /dev/null +++ b/app/lib/core/routing/url_replacer.dart @@ -0,0 +1,41 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:go_router/go_router.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'url_replacer.g.dart'; + +/// Replaces the browser URL of the current page without a new history entry +/// and without rebuilding the page (so state, scroll position and focus stay). +/// +/// Web only: on other platforms there is no visible URL, nothing happens. +/// The router state is updated inside `Router.neglect` (no history entry from +/// the router), the address bar is replaced with +/// `SystemNavigator.routeInformationUpdated(replace: true)`. +class UrlReplacer { + const UrlReplacer({this.isWeb = kIsWeb}); + + /// Whether to write at all (injectable for tests). + final bool isWeb; + + void replace(BuildContext context, Uri uri) { + if (!isWeb) { + return; + } + final router = GoRouter.maybeOf(context); + if (router != null) { + final current = router.routeInformationProvider.value.uri; + if (current != uri) { + Router.neglect(context, () => router.go(uri.toString())); + } + } + unawaited(SystemNavigator.routeInformationUpdated(uri: uri, replace: true)); + } +} + +/// Override in tests. +@Riverpod(keepAlive: true) +UrlReplacer urlReplacer(Ref ref) => const UrlReplacer(); diff --git a/app/lib/core/routing/url_replacer.g.dart b/app/lib/core/routing/url_replacer.g.dart new file mode 100644 index 0000000..2b087bf --- /dev/null +++ b/app/lib/core/routing/url_replacer.g.dart @@ -0,0 +1,55 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'url_replacer.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning +/// Override in tests. + +@ProviderFor(urlReplacer) +final urlReplacerProvider = UrlReplacerProvider._(); + +/// Override in tests. + +final class UrlReplacerProvider + extends $FunctionalProvider + with $Provider { + /// Override in tests. + UrlReplacerProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'urlReplacerProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$urlReplacerHash(); + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + UrlReplacer create(Ref ref) { + return urlReplacer(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(UrlReplacer value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$urlReplacerHash() => r'84773dd072e19ea69ca1b6438f9f7f5dea7f44d9'; diff --git a/app/lib/core/widgets/app_list_footer.dart b/app/lib/core/widgets/app_list_footer.dart new file mode 100644 index 0000000..38bd22f --- /dev/null +++ b/app/lib/core/widgets/app_list_footer.dart @@ -0,0 +1,94 @@ +import 'package:flutter/material.dart'; +import 'package:shuffle/core/theme/app_spacing.dart'; + +/// Footer of a paged list: loading indicator, "load more" button or an error +/// with retry (QUE-06 criterion 4: the button is reachable by keyboard and +/// screen reader, auto-loading is only a convenience). +/// +/// Priority: [errorMessage] over [isLoading] over [hasMore]. Already loaded +/// items stay in the list in every case. All strings come from the caller. +class AppListFooter extends StatelessWidget { + const AppListFooter({ + required this.isLoading, + required this.hasMore, + required this.loadingLabel, + required this.loadMoreLabel, + required this.retryLabel, + required this.onLoadMore, + required this.onRetry, + super.key, + this.errorMessage, + this.loadMoreFocusNode, + }); + + final bool isLoading; + final bool hasMore; + final String? errorMessage; + + /// Spoken label of the progress indicator. + final String loadingLabel; + final String loadMoreLabel; + final String retryLabel; + final VoidCallback onLoadMore; + final VoidCallback onRetry; + + /// Focus node of the "load more" button (callers restore focus elsewhere + /// after loading). + final FocusNode? loadMoreFocusNode; + + @override + Widget build(BuildContext context) { + final message = errorMessage; + final Widget content; + if (message != null) { + content = Column( + mainAxisSize: MainAxisSize.min, + children: [ + Semantics( + container: true, + liveRegion: true, + child: Text( + message, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(color: Theme.of(context).colorScheme.error), + ), + ), + const SizedBox(height: AppSpacing.sm), + OutlinedButton( + key: const Key('list_footer_retry'), + onPressed: onRetry, + child: Text(retryLabel), + ), + ], + ); + } else if (isLoading) { + content = Semantics( + label: loadingLabel, + liveRegion: true, + child: const SizedBox( + key: Key('list_footer_loading'), + width: AppSpacing.xxxl, + height: AppSpacing.xxxl, + child: Padding( + padding: EdgeInsets.all(AppSpacing.md), + child: CircularProgressIndicator(strokeWidth: 3), + ), + ), + ); + } else if (hasMore) { + content = OutlinedButton( + key: const Key('list_footer_load_more'), + focusNode: loadMoreFocusNode, + onPressed: onLoadMore, + child: Text(loadMoreLabel), + ); + } else { + return const SizedBox(height: AppSpacing.lg); + } + return Padding( + padding: const EdgeInsets.symmetric(vertical: AppSpacing.lg), + child: Center(child: content), + ); + } +} diff --git a/app/lib/core/widgets/app_state_message.dart b/app/lib/core/widgets/app_state_message.dart new file mode 100644 index 0000000..4857cfe --- /dev/null +++ b/app/lib/core/widgets/app_state_message.dart @@ -0,0 +1,100 @@ +import 'package:flutter/material.dart'; +import 'package:shuffle/core/theme/app_spacing.dart'; + +/// An action button of [AppStateMessage]. +class AppStateAction { + const AppStateAction({ + required this.label, + required this.onPressed, + this.key, + }); + + final String label; + final VoidCallback onPressed; + final Key? key; +} + +/// Message for an empty or failed list: icon, title, optional hint and +/// optional actions (48 dp buttons). +/// +/// The text is a live region, so screen readers announce it when it appears +/// after a search. All strings come from the caller (l10n). The icon is +/// decorative and hidden from screen readers. +class AppStateMessage extends StatelessWidget { + const AppStateMessage({ + required this.icon, + required this.title, + super.key, + this.message, + this.actions = const [], + }); + + final IconData icon; + final String title; + final String? message; + + /// The first action is emphasized (tonal), the others are outlined. + final List actions; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + return Center( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.xl), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ExcludeSemantics( + child: Icon(icon, size: 48, color: scheme.onSurfaceVariant), + ), + const SizedBox(height: AppSpacing.lg), + Semantics( + container: true, + liveRegion: true, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + title, + textAlign: TextAlign.center, + style: theme.textTheme.titleMedium, + ), + if (message != null) ...[ + const SizedBox(height: AppSpacing.sm), + Text( + message!, + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + ], + ], + ), + ), + if (actions.isNotEmpty) const SizedBox(height: AppSpacing.lg), + for (var i = 0; i < actions.length; i++) ...[ + if (i > 0) const SizedBox(height: AppSpacing.sm), + i == 0 + ? FilledButton.tonal( + key: actions[i].key, + onPressed: actions[i].onPressed, + child: Text(actions[i].label), + ) + : OutlinedButton( + key: actions[i].key, + onPressed: actions[i].onPressed, + child: Text(actions[i].label), + ), + ], + ], + ), + ), + ), + ); + } +} diff --git a/app/lib/features/catalog/data/catalog_failure_mapper.dart b/app/lib/features/catalog/data/catalog_failure_mapper.dart new file mode 100644 index 0000000..e47769d --- /dev/null +++ b/app/lib/features/catalog/data/catalog_failure_mapper.dart @@ -0,0 +1,20 @@ +import 'dart:async'; + +import 'package:http/http.dart' as http; +import 'package:shuffle/core/platform/io_error.dart'; +import 'package:shuffle/features/catalog/domain/catalog_failure.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +/// Translates exceptions of `supabase_flutter` into a [CatalogFailure] +/// (Spec #3 § 5.2). +CatalogFailure mapCatalogError(Object error) { + if (error is TimeoutException || + error is http.ClientException || + isIoNetworkError(error)) { + return const CatalogFailure.network(); + } + if (error is PostgrestException && error.code == 'P0001') { + return const CatalogFailure.invalidInput(); + } + return const CatalogFailure.unexpected(); +} diff --git a/app/lib/features/catalog/data/catalog_game_summary_dto.dart b/app/lib/features/catalog/data/catalog_game_summary_dto.dart new file mode 100644 index 0000000..12d64f3 --- /dev/null +++ b/app/lib/features/catalog/data/catalog_game_summary_dto.dart @@ -0,0 +1,57 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:shuffle/features/catalog/domain/catalog_game_summary.dart'; + +part 'catalog_game_summary_dto.g.dart'; + +/// One row of `search_catalog_games` (Spec #3 § 4.2). Read only: no `toJson`. +/// +/// `total_count` and `match_rank` are not part of the DTO; the repository +/// reads the total from the first row and the app does not show the rank. +@JsonSerializable(createToJson: false, fieldRename: FieldRename.snake) +class CatalogGameSummaryDto { + const CatalogGameSummaryDto({ + required this.id, + required this.title, + required this.publisherNames, + this.year, + this.minPlayers, + this.maxPlayers, + this.minMinutes, + this.maxMinutes, + this.minAge, + this.imagePath, + this.matchedAltTitle, + }); + + factory CatalogGameSummaryDto.fromJson(Map json) => + _$CatalogGameSummaryDtoFromJson(json); + + final String id; + final String title; + final int? year; + final int? minPlayers; + final int? maxPlayers; + final int? minMinutes; + final int? maxMinutes; + final int? minAge; + final String? imagePath; + + /// The backend sends an empty array instead of `null`; be lenient anyway. + @JsonKey(defaultValue: []) + final List publisherNames; + final String? matchedAltTitle; + + CatalogGameSummary toDomain() => CatalogGameSummary( + id: id, + title: title, + year: year, + minPlayers: minPlayers, + maxPlayers: maxPlayers, + minMinutes: minMinutes, + maxMinutes: maxMinutes, + minAge: minAge, + imagePath: imagePath, + publisherNames: publisherNames, + matchedAltTitle: matchedAltTitle, + ); +} diff --git a/app/lib/features/catalog/data/catalog_game_summary_dto.g.dart b/app/lib/features/catalog/data/catalog_game_summary_dto.g.dart new file mode 100644 index 0000000..5054e91 --- /dev/null +++ b/app/lib/features/catalog/data/catalog_game_summary_dto.g.dart @@ -0,0 +1,27 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'catalog_game_summary_dto.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +CatalogGameSummaryDto _$CatalogGameSummaryDtoFromJson( + Map json, +) => CatalogGameSummaryDto( + id: json['id'] as String, + title: json['title'] as String, + publisherNames: + (json['publisher_names'] as List?) + ?.map((e) => e as String) + .toList() ?? + [], + year: (json['year'] as num?)?.toInt(), + minPlayers: (json['min_players'] as num?)?.toInt(), + maxPlayers: (json['max_players'] as num?)?.toInt(), + minMinutes: (json['min_minutes'] as num?)?.toInt(), + maxMinutes: (json['max_minutes'] as num?)?.toInt(), + minAge: (json['min_age'] as num?)?.toInt(), + imagePath: json['image_path'] as String?, + matchedAltTitle: json['matched_alt_title'] as String?, +); diff --git a/app/lib/features/catalog/data/catalog_term_dto.dart b/app/lib/features/catalog/data/catalog_term_dto.dart new file mode 100644 index 0000000..4510726 --- /dev/null +++ b/app/lib/features/catalog/data/catalog_term_dto.dart @@ -0,0 +1,23 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:shuffle/features/catalog/domain/catalog_term.dart'; + +part 'catalog_term_dto.g.dart'; + +/// One row of `genres` or `mechanics`. Read only: no `toJson`. +@JsonSerializable(createToJson: false, fieldRename: FieldRename.snake) +class CatalogTermDto { + const CatalogTermDto({ + required this.id, + required this.nameDe, + required this.nameEn, + }); + + factory CatalogTermDto.fromJson(Map json) => + _$CatalogTermDtoFromJson(json); + + final String id; + final String nameDe; + final String nameEn; + + CatalogTerm toDomain() => CatalogTerm(id: id, nameDe: nameDe, nameEn: nameEn); +} diff --git a/app/lib/features/catalog/data/catalog_term_dto.g.dart b/app/lib/features/catalog/data/catalog_term_dto.g.dart new file mode 100644 index 0000000..514e9b8 --- /dev/null +++ b/app/lib/features/catalog/data/catalog_term_dto.g.dart @@ -0,0 +1,14 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'catalog_term_dto.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +CatalogTermDto _$CatalogTermDtoFromJson(Map json) => + CatalogTermDto( + id: json['id'] as String, + nameDe: json['name_de'] as String, + nameEn: json['name_en'] as String, + ); diff --git a/app/lib/features/catalog/data/supabase_catalog_repository.dart b/app/lib/features/catalog/data/supabase_catalog_repository.dart new file mode 100644 index 0000000..2edee36 --- /dev/null +++ b/app/lib/features/catalog/data/supabase_catalog_repository.dart @@ -0,0 +1,113 @@ +import 'dart:async'; + +import 'package:shuffle/core/logging/app_logger.dart'; +import 'package:shuffle/core/result/result.dart'; +import 'package:shuffle/features/catalog/data/catalog_failure_mapper.dart'; +import 'package:shuffle/features/catalog/data/catalog_game_summary_dto.dart'; +import 'package:shuffle/features/catalog/data/catalog_term_dto.dart'; +import 'package:shuffle/features/catalog/domain/catalog_failure.dart'; +import 'package:shuffle/features/catalog/domain/catalog_repository.dart'; +import 'package:shuffle/features/catalog/domain/catalog_search_page.dart'; +import 'package:shuffle/features/catalog/domain/catalog_search_query.dart'; +import 'package:shuffle/features/catalog/domain/catalog_term.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +final _log = AppLogger('CatalogRepository'); + +/// [CatalogRepository] against Supabase: the RPC `search_catalog_games` +/// (contract: Spec #3 § 4.2) and the vocabulary tables. +/// +/// Read only. Privacy (QUE-04): errors are logged by kind only, never with the +/// search text or the exception message (which could echo the parameters). +class SupabaseCatalogRepository implements CatalogRepository { + SupabaseCatalogRepository( + this._client, { + this.timeout = const Duration(seconds: 10), + }); + + final SupabaseClient _client; + + /// Time limit per call. + final Duration timeout; + + /// Builds the RPC parameters from set values only (Spec #3 § 5.2): no + /// `null` for empty sets, no search text below the minimum length. + static Map buildSearchParams( + CatalogSearchQuery query, { + required int offset, + required int limit, + }) { + final effective = query.effective; + final filters = effective.filters; + return { + if (effective.hasText) 'p_query': effective.text, + 'p_players': ?filters.players, + 'p_max_minutes': ?filters.maxMinutes, + 'p_age': ?filters.minAgeSuitable, + if (filters.genreIds.isNotEmpty) + 'p_genre_ids': filters.genreIds.toList(growable: false), + if (filters.mechanicIds.isNotEmpty) + 'p_mechanic_ids': filters.mechanicIds.toList(growable: false), + 'p_limit': limit, + 'p_offset': offset, + }; + } + + @override + Future> search( + CatalogSearchQuery query, { + required int offset, + int limit = CatalogSearchLimits.pageSize, + }) async { + try { + final params = buildSearchParams(query, offset: offset, limit: limit); + final response = await _client + .rpc('search_catalog_games', params: params) + .timeout(timeout); + final rows = (response as List).cast>(); + final items = [ + for (final row in rows) CatalogGameSummaryDto.fromJson(row).toDomain(), + ]; + final total = rows.isEmpty ? 0 : (rows.first['total_count'] as num); + return Result.success( + CatalogSearchPage(items: items, totalCount: total.toInt()), + ); + } on Object catch (error) { + return Result.failure(_fail('search', error)); + } + } + + @override + Future, CatalogFailure>> fetchGenres() => + _fetchTerms('genres'); + + @override + Future, CatalogFailure>> fetchMechanics() => + _fetchTerms('mechanics'); + + Future, CatalogFailure>> _fetchTerms( + String table, + ) async { + try { + final rows = await _client + .from(table) + .select('id, name_de, name_en') + .timeout(timeout); + return Result.success([ + for (final row in rows) CatalogTermDto.fromJson(row).toDomain(), + ]); + } on Object catch (error) { + return Result.failure(_fail(table, error)); + } + } + + /// Maps and logs an error. Logs the failure kind and the error type only. + CatalogFailure _fail(String operation, Object error) { + final failure = mapCatalogError(error); + _log.warning( + 'Catalog $operation failed: ${failure.runtimeType} ' + '(${error.runtimeType})', + ); + return failure; + } +} diff --git a/app/lib/features/catalog/domain/catalog_failure.dart b/app/lib/features/catalog/domain/catalog_failure.dart new file mode 100644 index 0000000..64144e7 --- /dev/null +++ b/app/lib/features/catalog/domain/catalog_failure.dart @@ -0,0 +1,16 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'catalog_failure.freezed.dart'; + +/// Expected failures of catalog calls (Spec #3 § 5.2). +@freezed +sealed class CatalogFailure with _$CatalogFailure { + /// No connection or timeout. + const factory CatalogFailure.network() = CatalogNetworkFailure; + + /// The backend rejected the input (`P0001`). The app prevents this at input + /// time, so it is a programming error. + const factory CatalogFailure.invalidInput() = CatalogInvalidInputFailure; + + const factory CatalogFailure.unexpected() = CatalogUnexpectedFailure; +} diff --git a/app/lib/features/catalog/domain/catalog_failure.freezed.dart b/app/lib/features/catalog/domain/catalog_failure.freezed.dart new file mode 100644 index 0000000..43b477a --- /dev/null +++ b/app/lib/features/catalog/domain/catalog_failure.freezed.dart @@ -0,0 +1,277 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint, type=warning, deprecated_member_use, deprecated_member_use_from_same_package +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'catalog_failure.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$CatalogFailure { + + + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is CatalogFailure); +} + + +@override +int get hashCode => runtimeType.hashCode; + +@override +String toString() { + return 'CatalogFailure()'; +} + + +} + +/// @nodoc +class $CatalogFailureCopyWith<$Res> { +$CatalogFailureCopyWith(CatalogFailure _, $Res Function(CatalogFailure) __); +} + + +/// Adds pattern-matching-related methods to [CatalogFailure]. +extension CatalogFailurePatterns on CatalogFailure { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap({TResult Function( CatalogNetworkFailure value)? network,TResult Function( CatalogInvalidInputFailure value)? invalidInput,TResult Function( CatalogUnexpectedFailure value)? unexpected,required TResult orElse(),}){ +final _that = this; +switch (_that) { +case CatalogNetworkFailure() when network != null: +return network(_that);case CatalogInvalidInputFailure() when invalidInput != null: +return invalidInput(_that);case CatalogUnexpectedFailure() when unexpected != null: +return unexpected(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map({required TResult Function( CatalogNetworkFailure value) network,required TResult Function( CatalogInvalidInputFailure value) invalidInput,required TResult Function( CatalogUnexpectedFailure value) unexpected,}){ +final _that = this; +switch (_that) { +case CatalogNetworkFailure(): +return network(_that);case CatalogInvalidInputFailure(): +return invalidInput(_that);case CatalogUnexpectedFailure(): +return unexpected(_that);} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull({TResult? Function( CatalogNetworkFailure value)? network,TResult? Function( CatalogInvalidInputFailure value)? invalidInput,TResult? Function( CatalogUnexpectedFailure value)? unexpected,}){ +final _that = this; +switch (_that) { +case CatalogNetworkFailure() when network != null: +return network(_that);case CatalogInvalidInputFailure() when invalidInput != null: +return invalidInput(_that);case CatalogUnexpectedFailure() when unexpected != null: +return unexpected(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen({TResult Function()? network,TResult Function()? invalidInput,TResult Function()? unexpected,required TResult orElse(),}) {final _that = this; +switch (_that) { +case CatalogNetworkFailure() when network != null: +return network();case CatalogInvalidInputFailure() when invalidInput != null: +return invalidInput();case CatalogUnexpectedFailure() when unexpected != null: +return unexpected();case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when({required TResult Function() network,required TResult Function() invalidInput,required TResult Function() unexpected,}) {final _that = this; +switch (_that) { +case CatalogNetworkFailure(): +return network();case CatalogInvalidInputFailure(): +return invalidInput();case CatalogUnexpectedFailure(): +return unexpected();} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull({TResult? Function()? network,TResult? Function()? invalidInput,TResult? Function()? unexpected,}) {final _that = this; +switch (_that) { +case CatalogNetworkFailure() when network != null: +return network();case CatalogInvalidInputFailure() when invalidInput != null: +return invalidInput();case CatalogUnexpectedFailure() when unexpected != null: +return unexpected();case _: + return null; + +} +} + +} + +/// @nodoc + + +class CatalogNetworkFailure implements CatalogFailure { + const CatalogNetworkFailure(); + + + + + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is CatalogNetworkFailure); +} + + +@override +int get hashCode => runtimeType.hashCode; + +@override +String toString() { + return 'CatalogFailure.network()'; +} + + +} + + + + +/// @nodoc + + +class CatalogInvalidInputFailure implements CatalogFailure { + const CatalogInvalidInputFailure(); + + + + + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is CatalogInvalidInputFailure); +} + + +@override +int get hashCode => runtimeType.hashCode; + +@override +String toString() { + return 'CatalogFailure.invalidInput()'; +} + + +} + + + + +/// @nodoc + + +class CatalogUnexpectedFailure implements CatalogFailure { + const CatalogUnexpectedFailure(); + + + + + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is CatalogUnexpectedFailure); +} + + +@override +int get hashCode => runtimeType.hashCode; + +@override +String toString() { + return 'CatalogFailure.unexpected()'; +} + + +} + + + + +// dart format on diff --git a/app/lib/features/catalog/domain/catalog_game_summary.dart b/app/lib/features/catalog/domain/catalog_game_summary.dart new file mode 100644 index 0000000..5070d98 --- /dev/null +++ b/app/lib/features/catalog/domain/catalog_game_summary.dart @@ -0,0 +1,30 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'catalog_game_summary.freezed.dart'; + +/// A catalog game as shown in a result list (Spec #3 § 5.2). +/// +/// Read only: there is intentionally no JSON conversion, nothing here is ever +/// written back to the database. The detail model `CatalogGame` comes with +/// KAT-02 (#4). +@freezed +abstract class CatalogGameSummary with _$CatalogGameSummary { + const factory CatalogGameSummary({ + required String id, + required String title, + int? year, + int? minPlayers, + int? maxPlayers, + int? minMinutes, + int? maxMinutes, + int? minAge, + + /// Storage path from `catalog_games`, not a URL. Not turned into an image + /// URL in #3 (the cover stays a placeholder). + String? imagePath, + @Default([]) List publisherNames, + + /// Alternative title that matched better than the main title. + String? matchedAltTitle, + }) = _CatalogGameSummary; +} diff --git a/app/lib/features/catalog/domain/catalog_game_summary.freezed.dart b/app/lib/features/catalog/domain/catalog_game_summary.freezed.dart new file mode 100644 index 0000000..ca4f14b --- /dev/null +++ b/app/lib/features/catalog/domain/catalog_game_summary.freezed.dart @@ -0,0 +1,321 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint, type=warning, deprecated_member_use, deprecated_member_use_from_same_package +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'catalog_game_summary.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$CatalogGameSummary { + + String get id; String get title; int? get year; int? get minPlayers; int? get maxPlayers; int? get minMinutes; int? get maxMinutes; int? get minAge;/// Storage path from `catalog_games`, not a URL. Not turned into an image +/// URL in #3 (the cover stays a placeholder). + String? get imagePath; List get publisherNames;/// Alternative title that matched better than the main title. + String? get matchedAltTitle; +/// Create a copy of CatalogGameSummary +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$CatalogGameSummaryCopyWith get copyWith => _$CatalogGameSummaryCopyWithImpl(this as CatalogGameSummary, _$identity); + + + +@override +bool operator ==(Object other) { + final _this = this as CatalogGameSummary; + return identical(this, other) || (other.runtimeType == runtimeType&&other is CatalogGameSummary&&(identical(other.id, _this.id) || other.id == _this.id)&&(identical(other.title, _this.title) || other.title == _this.title)&&(identical(other.year, _this.year) || other.year == _this.year)&&(identical(other.minPlayers, _this.minPlayers) || other.minPlayers == _this.minPlayers)&&(identical(other.maxPlayers, _this.maxPlayers) || other.maxPlayers == _this.maxPlayers)&&(identical(other.minMinutes, _this.minMinutes) || other.minMinutes == _this.minMinutes)&&(identical(other.maxMinutes, _this.maxMinutes) || other.maxMinutes == _this.maxMinutes)&&(identical(other.minAge, _this.minAge) || other.minAge == _this.minAge)&&(identical(other.imagePath, _this.imagePath) || other.imagePath == _this.imagePath)&&const DeepCollectionEquality().equals(other.publisherNames, _this.publisherNames)&&(identical(other.matchedAltTitle, _this.matchedAltTitle) || other.matchedAltTitle == _this.matchedAltTitle)); +} + + +@override +int get hashCode { + final _this = this as CatalogGameSummary; + return Object.hash(runtimeType,_this.id,_this.title,_this.year,_this.minPlayers,_this.maxPlayers,_this.minMinutes,_this.maxMinutes,_this.minAge,_this.imagePath,const DeepCollectionEquality().hash(_this.publisherNames),_this.matchedAltTitle); +} + +@override +String toString() { + final _this = this as CatalogGameSummary; + return 'CatalogGameSummary(id: ${_this.id}, title: ${_this.title}, year: ${_this.year}, minPlayers: ${_this.minPlayers}, maxPlayers: ${_this.maxPlayers}, minMinutes: ${_this.minMinutes}, maxMinutes: ${_this.maxMinutes}, minAge: ${_this.minAge}, imagePath: ${_this.imagePath}, publisherNames: ${_this.publisherNames}, matchedAltTitle: ${_this.matchedAltTitle})'; +} + + +} + +/// @nodoc +abstract mixin class $CatalogGameSummaryCopyWith<$Res> { + factory $CatalogGameSummaryCopyWith(CatalogGameSummary value, $Res Function(CatalogGameSummary) _then) = _$CatalogGameSummaryCopyWithImpl; +@useResult +$Res call({ + String id, String title, int? year, int? minPlayers, int? maxPlayers, int? minMinutes, int? maxMinutes, int? minAge, String? imagePath, List publisherNames, String? matchedAltTitle +}); + + + + +} +/// @nodoc +class _$CatalogGameSummaryCopyWithImpl<$Res> + implements $CatalogGameSummaryCopyWith<$Res> { + _$CatalogGameSummaryCopyWithImpl(this._self, this._then); + + final CatalogGameSummary _self; + final $Res Function(CatalogGameSummary) _then; + +/// Create a copy of CatalogGameSummary +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? title = null,Object? year = freezed,Object? minPlayers = freezed,Object? maxPlayers = freezed,Object? minMinutes = freezed,Object? maxMinutes = freezed,Object? minAge = freezed,Object? imagePath = freezed,Object? publisherNames = null,Object? matchedAltTitle = freezed,}) { + return _then(CatalogGameSummary( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable +as String,year: freezed == year ? _self.year : year // ignore: cast_nullable_to_non_nullable +as int?,minPlayers: freezed == minPlayers ? _self.minPlayers : minPlayers // ignore: cast_nullable_to_non_nullable +as int?,maxPlayers: freezed == maxPlayers ? _self.maxPlayers : maxPlayers // ignore: cast_nullable_to_non_nullable +as int?,minMinutes: freezed == minMinutes ? _self.minMinutes : minMinutes // ignore: cast_nullable_to_non_nullable +as int?,maxMinutes: freezed == maxMinutes ? _self.maxMinutes : maxMinutes // ignore: cast_nullable_to_non_nullable +as int?,minAge: freezed == minAge ? _self.minAge : minAge // ignore: cast_nullable_to_non_nullable +as int?,imagePath: freezed == imagePath ? _self.imagePath : imagePath // ignore: cast_nullable_to_non_nullable +as String?,publisherNames: null == publisherNames ? _self.publisherNames : publisherNames // ignore: cast_nullable_to_non_nullable +as List,matchedAltTitle: freezed == matchedAltTitle ? _self.matchedAltTitle : matchedAltTitle // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [CatalogGameSummary]. +extension CatalogGameSummaryPatterns on CatalogGameSummary { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _CatalogGameSummary value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _CatalogGameSummary() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _CatalogGameSummary value) $default,){ +final _that = this; +switch (_that) { +case _CatalogGameSummary(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _CatalogGameSummary value)? $default,){ +final _that = this; +switch (_that) { +case _CatalogGameSummary() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String id, String title, int? year, int? minPlayers, int? maxPlayers, int? minMinutes, int? maxMinutes, int? minAge, String? imagePath, List publisherNames, String? matchedAltTitle)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _CatalogGameSummary() when $default != null: +return $default(_that.id,_that.title,_that.year,_that.minPlayers,_that.maxPlayers,_that.minMinutes,_that.maxMinutes,_that.minAge,_that.imagePath,_that.publisherNames,_that.matchedAltTitle);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String id, String title, int? year, int? minPlayers, int? maxPlayers, int? minMinutes, int? maxMinutes, int? minAge, String? imagePath, List publisherNames, String? matchedAltTitle) $default,) {final _that = this; +switch (_that) { +case _CatalogGameSummary(): +return $default(_that.id,_that.title,_that.year,_that.minPlayers,_that.maxPlayers,_that.minMinutes,_that.maxMinutes,_that.minAge,_that.imagePath,_that.publisherNames,_that.matchedAltTitle);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String id, String title, int? year, int? minPlayers, int? maxPlayers, int? minMinutes, int? maxMinutes, int? minAge, String? imagePath, List publisherNames, String? matchedAltTitle)? $default,) {final _that = this; +switch (_that) { +case _CatalogGameSummary() when $default != null: +return $default(_that.id,_that.title,_that.year,_that.minPlayers,_that.maxPlayers,_that.minMinutes,_that.maxMinutes,_that.minAge,_that.imagePath,_that.publisherNames,_that.matchedAltTitle);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _CatalogGameSummary implements CatalogGameSummary { + const _CatalogGameSummary({required this.id, required this.title, this.year, this.minPlayers, this.maxPlayers, this.minMinutes, this.maxMinutes, this.minAge, this.imagePath, List publisherNames = const [], this.matchedAltTitle}): _publisherNames = publisherNames; + + +@override final String id; +@override final String title; +@override final int? year; +@override final int? minPlayers; +@override final int? maxPlayers; +@override final int? minMinutes; +@override final int? maxMinutes; +@override final int? minAge; +/// Storage path from `catalog_games`, not a URL. Not turned into an image +/// URL in #3 (the cover stays a placeholder). +@override final String? imagePath; + final List _publisherNames; +@override@JsonKey() List get publisherNames { + if (_publisherNames is EqualUnmodifiableListView) return _publisherNames; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_publisherNames); +} + +/// Alternative title that matched better than the main title. +@override final String? matchedAltTitle; + +/// Create a copy of CatalogGameSummary +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$CatalogGameSummaryCopyWith<_CatalogGameSummary> get copyWith => __$CatalogGameSummaryCopyWithImpl<_CatalogGameSummary>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _CatalogGameSummary&&(identical(other.id, id) || other.id == id)&&(identical(other.title, title) || other.title == title)&&(identical(other.year, year) || other.year == year)&&(identical(other.minPlayers, minPlayers) || other.minPlayers == minPlayers)&&(identical(other.maxPlayers, maxPlayers) || other.maxPlayers == maxPlayers)&&(identical(other.minMinutes, minMinutes) || other.minMinutes == minMinutes)&&(identical(other.maxMinutes, maxMinutes) || other.maxMinutes == maxMinutes)&&(identical(other.minAge, minAge) || other.minAge == minAge)&&(identical(other.imagePath, imagePath) || other.imagePath == imagePath)&&const DeepCollectionEquality().equals(other.publisherNames, _publisherNames)&&(identical(other.matchedAltTitle, matchedAltTitle) || other.matchedAltTitle == matchedAltTitle)); +} + + +@override +int get hashCode { + return Object.hash(runtimeType,id,title,year,minPlayers,maxPlayers,minMinutes,maxMinutes,minAge,imagePath,const DeepCollectionEquality().hash(_publisherNames),matchedAltTitle); +} + +@override +String toString() { + return 'CatalogGameSummary(id: $id, title: $title, year: $year, minPlayers: $minPlayers, maxPlayers: $maxPlayers, minMinutes: $minMinutes, maxMinutes: $maxMinutes, minAge: $minAge, imagePath: $imagePath, publisherNames: $publisherNames, matchedAltTitle: $matchedAltTitle)'; +} + + +} + +/// @nodoc +abstract mixin class _$CatalogGameSummaryCopyWith<$Res> implements $CatalogGameSummaryCopyWith<$Res> { + factory _$CatalogGameSummaryCopyWith(_CatalogGameSummary value, $Res Function(_CatalogGameSummary) _then) = __$CatalogGameSummaryCopyWithImpl; +@override @useResult +$Res call({ + String id, String title, int? year, int? minPlayers, int? maxPlayers, int? minMinutes, int? maxMinutes, int? minAge, String? imagePath, List publisherNames, String? matchedAltTitle +}); + + + + +} +/// @nodoc +class __$CatalogGameSummaryCopyWithImpl<$Res> + implements _$CatalogGameSummaryCopyWith<$Res> { + __$CatalogGameSummaryCopyWithImpl(this._self, this._then); + + final _CatalogGameSummary _self; + final $Res Function(_CatalogGameSummary) _then; + +/// Create a copy of CatalogGameSummary +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? title = null,Object? year = freezed,Object? minPlayers = freezed,Object? maxPlayers = freezed,Object? minMinutes = freezed,Object? maxMinutes = freezed,Object? minAge = freezed,Object? imagePath = freezed,Object? publisherNames = null,Object? matchedAltTitle = freezed,}) { + return _then(_CatalogGameSummary( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable +as String,year: freezed == year ? _self.year : year // ignore: cast_nullable_to_non_nullable +as int?,minPlayers: freezed == minPlayers ? _self.minPlayers : minPlayers // ignore: cast_nullable_to_non_nullable +as int?,maxPlayers: freezed == maxPlayers ? _self.maxPlayers : maxPlayers // ignore: cast_nullable_to_non_nullable +as int?,minMinutes: freezed == minMinutes ? _self.minMinutes : minMinutes // ignore: cast_nullable_to_non_nullable +as int?,maxMinutes: freezed == maxMinutes ? _self.maxMinutes : maxMinutes // ignore: cast_nullable_to_non_nullable +as int?,minAge: freezed == minAge ? _self.minAge : minAge // ignore: cast_nullable_to_non_nullable +as int?,imagePath: freezed == imagePath ? _self.imagePath : imagePath // ignore: cast_nullable_to_non_nullable +as String?,publisherNames: null == publisherNames ? _self._publisherNames : publisherNames // ignore: cast_nullable_to_non_nullable +as List,matchedAltTitle: freezed == matchedAltTitle ? _self.matchedAltTitle : matchedAltTitle // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + + +} + +// dart format on diff --git a/app/lib/features/catalog/domain/catalog_repository.dart b/app/lib/features/catalog/domain/catalog_repository.dart new file mode 100644 index 0000000..323b220 --- /dev/null +++ b/app/lib/features/catalog/domain/catalog_repository.dart @@ -0,0 +1,19 @@ +import 'package:shuffle/core/result/result.dart'; +import 'package:shuffle/features/catalog/domain/catalog_failure.dart'; +import 'package:shuffle/features/catalog/domain/catalog_search_page.dart'; +import 'package:shuffle/features/catalog/domain/catalog_search_query.dart'; +import 'package:shuffle/features/catalog/domain/catalog_term.dart'; + +/// Read access to the catalog (Spec #3 § 5.2). +abstract interface class CatalogRepository { + /// One page of hits for [query], starting at [offset]. + Future> search( + CatalogSearchQuery query, { + required int offset, + int limit = CatalogSearchLimits.pageSize, + }); + + Future, CatalogFailure>> fetchGenres(); + + Future, CatalogFailure>> fetchMechanics(); +} diff --git a/app/lib/features/catalog/domain/catalog_search_page.dart b/app/lib/features/catalog/domain/catalog_search_page.dart new file mode 100644 index 0000000..e9a931e --- /dev/null +++ b/app/lib/features/catalog/domain/catalog_search_page.dart @@ -0,0 +1,13 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:shuffle/features/catalog/domain/catalog_game_summary.dart'; + +part 'catalog_search_page.freezed.dart'; + +/// One page of search results plus the total number of hits. +@freezed +abstract class CatalogSearchPage with _$CatalogSearchPage { + const factory CatalogSearchPage({ + required List items, + required int totalCount, + }) = _CatalogSearchPage; +} diff --git a/app/lib/features/catalog/domain/catalog_search_page.freezed.dart b/app/lib/features/catalog/domain/catalog_search_page.freezed.dart new file mode 100644 index 0000000..2731abf --- /dev/null +++ b/app/lib/features/catalog/domain/catalog_search_page.freezed.dart @@ -0,0 +1,288 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint, type=warning, deprecated_member_use, deprecated_member_use_from_same_package +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'catalog_search_page.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$CatalogSearchPage { + + List get items; int get totalCount; +/// Create a copy of CatalogSearchPage +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$CatalogSearchPageCopyWith get copyWith => _$CatalogSearchPageCopyWithImpl(this as CatalogSearchPage, _$identity); + + + +@override +bool operator ==(Object other) { + final _this = this as CatalogSearchPage; + return identical(this, other) || (other.runtimeType == runtimeType&&other is CatalogSearchPage&&const DeepCollectionEquality().equals(other.items, _this.items)&&(identical(other.totalCount, _this.totalCount) || other.totalCount == _this.totalCount)); +} + + +@override +int get hashCode { + final _this = this as CatalogSearchPage; + return Object.hash(runtimeType,const DeepCollectionEquality().hash(_this.items),_this.totalCount); +} + +@override +String toString() { + final _this = this as CatalogSearchPage; + return 'CatalogSearchPage(items: ${_this.items}, totalCount: ${_this.totalCount})'; +} + + +} + +/// @nodoc +abstract mixin class $CatalogSearchPageCopyWith<$Res> { + factory $CatalogSearchPageCopyWith(CatalogSearchPage value, $Res Function(CatalogSearchPage) _then) = _$CatalogSearchPageCopyWithImpl; +@useResult +$Res call({ + List items, int totalCount +}); + + + + +} +/// @nodoc +class _$CatalogSearchPageCopyWithImpl<$Res> + implements $CatalogSearchPageCopyWith<$Res> { + _$CatalogSearchPageCopyWithImpl(this._self, this._then); + + final CatalogSearchPage _self; + final $Res Function(CatalogSearchPage) _then; + +/// Create a copy of CatalogSearchPage +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? items = null,Object? totalCount = null,}) { + return _then(CatalogSearchPage( +items: null == items ? _self.items : items // ignore: cast_nullable_to_non_nullable +as List,totalCount: null == totalCount ? _self.totalCount : totalCount // ignore: cast_nullable_to_non_nullable +as int, + )); +} + +} + + +/// Adds pattern-matching-related methods to [CatalogSearchPage]. +extension CatalogSearchPagePatterns on CatalogSearchPage { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _CatalogSearchPage value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _CatalogSearchPage() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _CatalogSearchPage value) $default,){ +final _that = this; +switch (_that) { +case _CatalogSearchPage(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _CatalogSearchPage value)? $default,){ +final _that = this; +switch (_that) { +case _CatalogSearchPage() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( List items, int totalCount)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _CatalogSearchPage() when $default != null: +return $default(_that.items,_that.totalCount);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( List items, int totalCount) $default,) {final _that = this; +switch (_that) { +case _CatalogSearchPage(): +return $default(_that.items,_that.totalCount);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( List items, int totalCount)? $default,) {final _that = this; +switch (_that) { +case _CatalogSearchPage() when $default != null: +return $default(_that.items,_that.totalCount);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _CatalogSearchPage implements CatalogSearchPage { + const _CatalogSearchPage({required List items, required this.totalCount}): _items = items; + + + final List _items; +@override List get items { + if (_items is EqualUnmodifiableListView) return _items; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_items); +} + +@override final int totalCount; + +/// Create a copy of CatalogSearchPage +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$CatalogSearchPageCopyWith<_CatalogSearchPage> get copyWith => __$CatalogSearchPageCopyWithImpl<_CatalogSearchPage>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _CatalogSearchPage&&const DeepCollectionEquality().equals(other.items, _items)&&(identical(other.totalCount, totalCount) || other.totalCount == totalCount)); +} + + +@override +int get hashCode { + return Object.hash(runtimeType,const DeepCollectionEquality().hash(_items),totalCount); +} + +@override +String toString() { + return 'CatalogSearchPage(items: $items, totalCount: $totalCount)'; +} + + +} + +/// @nodoc +abstract mixin class _$CatalogSearchPageCopyWith<$Res> implements $CatalogSearchPageCopyWith<$Res> { + factory _$CatalogSearchPageCopyWith(_CatalogSearchPage value, $Res Function(_CatalogSearchPage) _then) = __$CatalogSearchPageCopyWithImpl; +@override @useResult +$Res call({ + List items, int totalCount +}); + + + + +} +/// @nodoc +class __$CatalogSearchPageCopyWithImpl<$Res> + implements _$CatalogSearchPageCopyWith<$Res> { + __$CatalogSearchPageCopyWithImpl(this._self, this._then); + + final _CatalogSearchPage _self; + final $Res Function(_CatalogSearchPage) _then; + +/// Create a copy of CatalogSearchPage +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? items = null,Object? totalCount = null,}) { + return _then(_CatalogSearchPage( +items: null == items ? _self._items : items // ignore: cast_nullable_to_non_nullable +as List,totalCount: null == totalCount ? _self.totalCount : totalCount // ignore: cast_nullable_to_non_nullable +as int, + )); +} + + +} + +// dart format on diff --git a/app/lib/features/catalog/domain/catalog_search_query.dart b/app/lib/features/catalog/domain/catalog_search_query.dart new file mode 100644 index 0000000..70d3035 --- /dev/null +++ b/app/lib/features/catalog/domain/catalog_search_query.dart @@ -0,0 +1,101 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'catalog_search_query.freezed.dart'; + +/// Allowed values and limits of the search (Spec #3 § 4.2, § 5.2). +abstract final class CatalogSearchLimits { + /// Longest search text (the backend rejects more). + static const int maxTextLength = 100; + + /// Searches start at this many characters; less acts like "no search text". + static const int minTextLength = 2; + + static const int minPlayers = 1; + static const int maxPlayers = 12; + + /// "Up to X minutes" steps. + static const List maxMinutesSteps = [30, 45, 60, 90, 120, 180]; + + /// "Suitable from X years" steps. + static const List ageSteps = [6, 8, 10, 12, 14, 16, 18]; + + /// Most genres or mechanics that can be selected at once. + static const int maxTerms = 20; + + static const int pageSize = 30; + + /// The backend does not page beyond this offset. + static const int maxOffset = 10000; +} + +/// Filters of a search. All set filters apply together (AND, Spec #3 E7). +@freezed +abstract class CatalogFilters with _$CatalogFilters { + @Assert( + 'players == null || (players >= 1 && players <= 12)', + 'players out of range', + ) + @Assert('maxMinutes == null || maxMinutes > 0', 'maxMinutes must be positive') + @Assert('minAgeSuitable == null || minAgeSuitable >= 0', 'age out of range') + const factory CatalogFilters({ + /// Number of players the game must support. + int? players, + + /// Game lasts at most this many minutes. + int? maxMinutes, + + /// Game is suitable for players of this age (minimum age at most this). + int? minAgeSuitable, + + /// At most [CatalogSearchLimits.maxTerms]; the UI and the URL parser + /// enforce it. + @Default({}) Set genreIds, + @Default({}) Set mechanicIds, + }) = _CatalogFilters; + + const CatalogFilters._(); + + int get activeCount => + (players != null ? 1 : 0) + + (maxMinutes != null ? 1 : 0) + + (minAgeSuitable != null ? 1 : 0) + + genreIds.length + + mechanicIds.length; + + bool get isEmpty => activeCount == 0; +} + +/// A search: text plus filters. +@freezed +abstract class CatalogSearchQuery with _$CatalogSearchQuery { + @Assert('text.length <= 100', 'text longer than the maximum length') + const factory CatalogSearchQuery({ + /// Trimmed search text, at most 100 characters. + @Default('') String text, + @Default(CatalogFilters()) CatalogFilters filters, + }) = _CatalogSearchQuery; + + const CatalogSearchQuery._(); + + /// Trims [raw] and cuts it to the maximum length. + static String cleanText(String raw) { + final trimmed = raw.trim(); + return trimmed.length > CatalogSearchLimits.maxTextLength + ? trimmed.substring(0, CatalogSearchLimits.maxTextLength).trim() + : trimmed; + } + + /// Whether the text is long enough to count as a search term. + bool get hasText => text.length >= CatalogSearchLimits.minTextLength; + + bool get hasFilters => !filters.isEmpty; + + int get activeFilterCount => filters.activeCount; + + /// No search term and no filters: the whole catalog (Spec #3 E8). + bool get isEmpty => !hasText && !hasFilters; + + /// The query as the backend sees it: a text below the minimum length is + /// dropped. + CatalogSearchQuery get effective => hasText ? this : copyWith(text: ''); +} diff --git a/app/lib/features/catalog/domain/catalog_search_query.freezed.dart b/app/lib/features/catalog/domain/catalog_search_query.freezed.dart new file mode 100644 index 0000000..b40b6e9 --- /dev/null +++ b/app/lib/features/catalog/domain/catalog_search_query.freezed.dart @@ -0,0 +1,602 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint, type=warning, deprecated_member_use, deprecated_member_use_from_same_package +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'catalog_search_query.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$CatalogFilters { + +/// Number of players the game must support. + int? get players;/// Game lasts at most this many minutes. + int? get maxMinutes;/// Game is suitable for players of this age (minimum age at most this). + int? get minAgeSuitable;/// At most [CatalogSearchLimits.maxTerms]; the UI and the URL parser +/// enforce it. + Set get genreIds; Set get mechanicIds; +/// Create a copy of CatalogFilters +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$CatalogFiltersCopyWith get copyWith => _$CatalogFiltersCopyWithImpl(this as CatalogFilters, _$identity); + + + +@override +bool operator ==(Object other) { + final _this = this as CatalogFilters; + return identical(this, other) || (other.runtimeType == runtimeType&&other is CatalogFilters&&(identical(other.players, _this.players) || other.players == _this.players)&&(identical(other.maxMinutes, _this.maxMinutes) || other.maxMinutes == _this.maxMinutes)&&(identical(other.minAgeSuitable, _this.minAgeSuitable) || other.minAgeSuitable == _this.minAgeSuitable)&&const DeepCollectionEquality().equals(other.genreIds, _this.genreIds)&&const DeepCollectionEquality().equals(other.mechanicIds, _this.mechanicIds)); +} + + +@override +int get hashCode { + final _this = this as CatalogFilters; + return Object.hash(runtimeType,_this.players,_this.maxMinutes,_this.minAgeSuitable,const DeepCollectionEquality().hash(_this.genreIds),const DeepCollectionEquality().hash(_this.mechanicIds)); +} + +@override +String toString() { + final _this = this as CatalogFilters; + return 'CatalogFilters(players: ${_this.players}, maxMinutes: ${_this.maxMinutes}, minAgeSuitable: ${_this.minAgeSuitable}, genreIds: ${_this.genreIds}, mechanicIds: ${_this.mechanicIds})'; +} + + +} + +/// @nodoc +abstract mixin class $CatalogFiltersCopyWith<$Res> { + factory $CatalogFiltersCopyWith(CatalogFilters value, $Res Function(CatalogFilters) _then) = _$CatalogFiltersCopyWithImpl; +@useResult +$Res call({ + int? players, int? maxMinutes, int? minAgeSuitable, Set genreIds, Set mechanicIds +}); + + + + +} +/// @nodoc +class _$CatalogFiltersCopyWithImpl<$Res> + implements $CatalogFiltersCopyWith<$Res> { + _$CatalogFiltersCopyWithImpl(this._self, this._then); + + final CatalogFilters _self; + final $Res Function(CatalogFilters) _then; + +/// Create a copy of CatalogFilters +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? players = freezed,Object? maxMinutes = freezed,Object? minAgeSuitable = freezed,Object? genreIds = null,Object? mechanicIds = null,}) { + return _then(CatalogFilters( +players: freezed == players ? _self.players : players // ignore: cast_nullable_to_non_nullable +as int?,maxMinutes: freezed == maxMinutes ? _self.maxMinutes : maxMinutes // ignore: cast_nullable_to_non_nullable +as int?,minAgeSuitable: freezed == minAgeSuitable ? _self.minAgeSuitable : minAgeSuitable // ignore: cast_nullable_to_non_nullable +as int?,genreIds: null == genreIds ? _self.genreIds : genreIds // ignore: cast_nullable_to_non_nullable +as Set,mechanicIds: null == mechanicIds ? _self.mechanicIds : mechanicIds // ignore: cast_nullable_to_non_nullable +as Set, + )); +} + +} + + +/// Adds pattern-matching-related methods to [CatalogFilters]. +extension CatalogFiltersPatterns on CatalogFilters { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _CatalogFilters value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _CatalogFilters() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _CatalogFilters value) $default,){ +final _that = this; +switch (_that) { +case _CatalogFilters(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _CatalogFilters value)? $default,){ +final _that = this; +switch (_that) { +case _CatalogFilters() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( int? players, int? maxMinutes, int? minAgeSuitable, Set genreIds, Set mechanicIds)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _CatalogFilters() when $default != null: +return $default(_that.players,_that.maxMinutes,_that.minAgeSuitable,_that.genreIds,_that.mechanicIds);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( int? players, int? maxMinutes, int? minAgeSuitable, Set genreIds, Set mechanicIds) $default,) {final _that = this; +switch (_that) { +case _CatalogFilters(): +return $default(_that.players,_that.maxMinutes,_that.minAgeSuitable,_that.genreIds,_that.mechanicIds);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( int? players, int? maxMinutes, int? minAgeSuitable, Set genreIds, Set mechanicIds)? $default,) {final _that = this; +switch (_that) { +case _CatalogFilters() when $default != null: +return $default(_that.players,_that.maxMinutes,_that.minAgeSuitable,_that.genreIds,_that.mechanicIds);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _CatalogFilters extends CatalogFilters { + const _CatalogFilters({this.players, this.maxMinutes, this.minAgeSuitable, Set genreIds = const {}, Set mechanicIds = const {}}): assert(players == null || (players >= 1 && players <= 12), 'players out of range'),assert(maxMinutes == null || maxMinutes > 0, 'maxMinutes must be positive'),assert(minAgeSuitable == null || minAgeSuitable >= 0, 'age out of range'),_genreIds = genreIds,_mechanicIds = mechanicIds,super._(); + + +/// Number of players the game must support. +@override final int? players; +/// Game lasts at most this many minutes. +@override final int? maxMinutes; +/// Game is suitable for players of this age (minimum age at most this). +@override final int? minAgeSuitable; +/// At most [CatalogSearchLimits.maxTerms]; the UI and the URL parser +/// enforce it. + final Set _genreIds; +/// At most [CatalogSearchLimits.maxTerms]; the UI and the URL parser +/// enforce it. +@override@JsonKey() Set get genreIds { + if (_genreIds is EqualUnmodifiableSetView) return _genreIds; + // ignore: implicit_dynamic_type + return EqualUnmodifiableSetView(_genreIds); +} + + final Set _mechanicIds; +@override@JsonKey() Set get mechanicIds { + if (_mechanicIds is EqualUnmodifiableSetView) return _mechanicIds; + // ignore: implicit_dynamic_type + return EqualUnmodifiableSetView(_mechanicIds); +} + + +/// Create a copy of CatalogFilters +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$CatalogFiltersCopyWith<_CatalogFilters> get copyWith => __$CatalogFiltersCopyWithImpl<_CatalogFilters>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _CatalogFilters&&(identical(other.players, players) || other.players == players)&&(identical(other.maxMinutes, maxMinutes) || other.maxMinutes == maxMinutes)&&(identical(other.minAgeSuitable, minAgeSuitable) || other.minAgeSuitable == minAgeSuitable)&&const DeepCollectionEquality().equals(other.genreIds, _genreIds)&&const DeepCollectionEquality().equals(other.mechanicIds, _mechanicIds)); +} + + +@override +int get hashCode { + return Object.hash(runtimeType,players,maxMinutes,minAgeSuitable,const DeepCollectionEquality().hash(_genreIds),const DeepCollectionEquality().hash(_mechanicIds)); +} + +@override +String toString() { + return 'CatalogFilters(players: $players, maxMinutes: $maxMinutes, minAgeSuitable: $minAgeSuitable, genreIds: $genreIds, mechanicIds: $mechanicIds)'; +} + + +} + +/// @nodoc +abstract mixin class _$CatalogFiltersCopyWith<$Res> implements $CatalogFiltersCopyWith<$Res> { + factory _$CatalogFiltersCopyWith(_CatalogFilters value, $Res Function(_CatalogFilters) _then) = __$CatalogFiltersCopyWithImpl; +@override @useResult +$Res call({ + int? players, int? maxMinutes, int? minAgeSuitable, Set genreIds, Set mechanicIds +}); + + + + +} +/// @nodoc +class __$CatalogFiltersCopyWithImpl<$Res> + implements _$CatalogFiltersCopyWith<$Res> { + __$CatalogFiltersCopyWithImpl(this._self, this._then); + + final _CatalogFilters _self; + final $Res Function(_CatalogFilters) _then; + +/// Create a copy of CatalogFilters +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? players = freezed,Object? maxMinutes = freezed,Object? minAgeSuitable = freezed,Object? genreIds = null,Object? mechanicIds = null,}) { + return _then(_CatalogFilters( +players: freezed == players ? _self.players : players // ignore: cast_nullable_to_non_nullable +as int?,maxMinutes: freezed == maxMinutes ? _self.maxMinutes : maxMinutes // ignore: cast_nullable_to_non_nullable +as int?,minAgeSuitable: freezed == minAgeSuitable ? _self.minAgeSuitable : minAgeSuitable // ignore: cast_nullable_to_non_nullable +as int?,genreIds: null == genreIds ? _self._genreIds : genreIds // ignore: cast_nullable_to_non_nullable +as Set,mechanicIds: null == mechanicIds ? _self._mechanicIds : mechanicIds // ignore: cast_nullable_to_non_nullable +as Set, + )); +} + + +} + +/// @nodoc +mixin _$CatalogSearchQuery { + +/// Trimmed search text, at most 100 characters. + String get text; CatalogFilters get filters; +/// Create a copy of CatalogSearchQuery +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$CatalogSearchQueryCopyWith get copyWith => _$CatalogSearchQueryCopyWithImpl(this as CatalogSearchQuery, _$identity); + + + +@override +bool operator ==(Object other) { + final _this = this as CatalogSearchQuery; + return identical(this, other) || (other.runtimeType == runtimeType&&other is CatalogSearchQuery&&(identical(other.text, _this.text) || other.text == _this.text)&&(identical(other.filters, _this.filters) || other.filters == _this.filters)); +} + + +@override +int get hashCode { + final _this = this as CatalogSearchQuery; + return Object.hash(runtimeType,_this.text,_this.filters); +} + +@override +String toString() { + final _this = this as CatalogSearchQuery; + return 'CatalogSearchQuery(text: ${_this.text}, filters: ${_this.filters})'; +} + + +} + +/// @nodoc +abstract mixin class $CatalogSearchQueryCopyWith<$Res> { + factory $CatalogSearchQueryCopyWith(CatalogSearchQuery value, $Res Function(CatalogSearchQuery) _then) = _$CatalogSearchQueryCopyWithImpl; +@useResult +$Res call({ + String text, CatalogFilters filters +}); + + +$CatalogFiltersCopyWith<$Res> get filters; + +} +/// @nodoc +class _$CatalogSearchQueryCopyWithImpl<$Res> + implements $CatalogSearchQueryCopyWith<$Res> { + _$CatalogSearchQueryCopyWithImpl(this._self, this._then); + + final CatalogSearchQuery _self; + final $Res Function(CatalogSearchQuery) _then; + +/// Create a copy of CatalogSearchQuery +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? text = null,Object? filters = null,}) { + return _then(CatalogSearchQuery( +text: null == text ? _self.text : text // ignore: cast_nullable_to_non_nullable +as String,filters: null == filters ? _self.filters : filters // ignore: cast_nullable_to_non_nullable +as CatalogFilters, + )); +} +/// Create a copy of CatalogSearchQuery +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$CatalogFiltersCopyWith<$Res> get filters { + + return $CatalogFiltersCopyWith<$Res>(_self.filters, (value) { + return _then(_self.copyWith(filters: value)); + }); +} +} + + +/// Adds pattern-matching-related methods to [CatalogSearchQuery]. +extension CatalogSearchQueryPatterns on CatalogSearchQuery { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _CatalogSearchQuery value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _CatalogSearchQuery() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _CatalogSearchQuery value) $default,){ +final _that = this; +switch (_that) { +case _CatalogSearchQuery(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _CatalogSearchQuery value)? $default,){ +final _that = this; +switch (_that) { +case _CatalogSearchQuery() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String text, CatalogFilters filters)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _CatalogSearchQuery() when $default != null: +return $default(_that.text,_that.filters);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String text, CatalogFilters filters) $default,) {final _that = this; +switch (_that) { +case _CatalogSearchQuery(): +return $default(_that.text,_that.filters);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String text, CatalogFilters filters)? $default,) {final _that = this; +switch (_that) { +case _CatalogSearchQuery() when $default != null: +return $default(_that.text,_that.filters);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _CatalogSearchQuery extends CatalogSearchQuery { + const _CatalogSearchQuery({this.text = '', this.filters = const CatalogFilters()}): assert(text.length <= 100, 'text longer than the maximum length'),super._(); + + +/// Trimmed search text, at most 100 characters. +@override@JsonKey() final String text; +@override@JsonKey() final CatalogFilters filters; + +/// Create a copy of CatalogSearchQuery +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$CatalogSearchQueryCopyWith<_CatalogSearchQuery> get copyWith => __$CatalogSearchQueryCopyWithImpl<_CatalogSearchQuery>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _CatalogSearchQuery&&(identical(other.text, text) || other.text == text)&&(identical(other.filters, filters) || other.filters == filters)); +} + + +@override +int get hashCode { + return Object.hash(runtimeType,text,filters); +} + +@override +String toString() { + return 'CatalogSearchQuery(text: $text, filters: $filters)'; +} + + +} + +/// @nodoc +abstract mixin class _$CatalogSearchQueryCopyWith<$Res> implements $CatalogSearchQueryCopyWith<$Res> { + factory _$CatalogSearchQueryCopyWith(_CatalogSearchQuery value, $Res Function(_CatalogSearchQuery) _then) = __$CatalogSearchQueryCopyWithImpl; +@override @useResult +$Res call({ + String text, CatalogFilters filters +}); + + +@override $CatalogFiltersCopyWith<$Res> get filters; + +} +/// @nodoc +class __$CatalogSearchQueryCopyWithImpl<$Res> + implements _$CatalogSearchQueryCopyWith<$Res> { + __$CatalogSearchQueryCopyWithImpl(this._self, this._then); + + final _CatalogSearchQuery _self; + final $Res Function(_CatalogSearchQuery) _then; + +/// Create a copy of CatalogSearchQuery +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? text = null,Object? filters = null,}) { + return _then(_CatalogSearchQuery( +text: null == text ? _self.text : text // ignore: cast_nullable_to_non_nullable +as String,filters: null == filters ? _self.filters : filters // ignore: cast_nullable_to_non_nullable +as CatalogFilters, + )); +} + +/// Create a copy of CatalogSearchQuery +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$CatalogFiltersCopyWith<$Res> get filters { + + return $CatalogFiltersCopyWith<$Res>(_self.filters, (value) { + return _then(_self.copyWith(filters: value)); + }); +} +} + +// dart format on diff --git a/app/lib/features/catalog/domain/catalog_term.dart b/app/lib/features/catalog/domain/catalog_term.dart new file mode 100644 index 0000000..8bd71df --- /dev/null +++ b/app/lib/features/catalog/domain/catalog_term.dart @@ -0,0 +1,20 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'catalog_term.freezed.dart'; + +/// A controlled vocabulary entry (genre or mechanic) with both languages. +@freezed +abstract class CatalogTerm with _$CatalogTerm { + const factory CatalogTerm({ + required String id, + required String nameDe, + required String nameEn, + }) = _CatalogTerm; + + const CatalogTerm._(); + + /// German name for `de`, English for every other language (QUE-05 criterion + /// 4: English is the fallback). + String localizedName(String languageCode) => + languageCode == 'de' ? nameDe : nameEn; +} diff --git a/app/lib/features/catalog/domain/catalog_term.freezed.dart b/app/lib/features/catalog/domain/catalog_term.freezed.dart new file mode 100644 index 0000000..c25f96a --- /dev/null +++ b/app/lib/features/catalog/domain/catalog_term.freezed.dart @@ -0,0 +1,285 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint, type=warning, deprecated_member_use, deprecated_member_use_from_same_package +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'catalog_term.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$CatalogTerm { + + String get id; String get nameDe; String get nameEn; +/// Create a copy of CatalogTerm +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$CatalogTermCopyWith get copyWith => _$CatalogTermCopyWithImpl(this as CatalogTerm, _$identity); + + + +@override +bool operator ==(Object other) { + final _this = this as CatalogTerm; + return identical(this, other) || (other.runtimeType == runtimeType&&other is CatalogTerm&&(identical(other.id, _this.id) || other.id == _this.id)&&(identical(other.nameDe, _this.nameDe) || other.nameDe == _this.nameDe)&&(identical(other.nameEn, _this.nameEn) || other.nameEn == _this.nameEn)); +} + + +@override +int get hashCode { + final _this = this as CatalogTerm; + return Object.hash(runtimeType,_this.id,_this.nameDe,_this.nameEn); +} + +@override +String toString() { + final _this = this as CatalogTerm; + return 'CatalogTerm(id: ${_this.id}, nameDe: ${_this.nameDe}, nameEn: ${_this.nameEn})'; +} + + +} + +/// @nodoc +abstract mixin class $CatalogTermCopyWith<$Res> { + factory $CatalogTermCopyWith(CatalogTerm value, $Res Function(CatalogTerm) _then) = _$CatalogTermCopyWithImpl; +@useResult +$Res call({ + String id, String nameDe, String nameEn +}); + + + + +} +/// @nodoc +class _$CatalogTermCopyWithImpl<$Res> + implements $CatalogTermCopyWith<$Res> { + _$CatalogTermCopyWithImpl(this._self, this._then); + + final CatalogTerm _self; + final $Res Function(CatalogTerm) _then; + +/// Create a copy of CatalogTerm +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? nameDe = null,Object? nameEn = null,}) { + return _then(CatalogTerm( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,nameDe: null == nameDe ? _self.nameDe : nameDe // ignore: cast_nullable_to_non_nullable +as String,nameEn: null == nameEn ? _self.nameEn : nameEn // ignore: cast_nullable_to_non_nullable +as String, + )); +} + +} + + +/// Adds pattern-matching-related methods to [CatalogTerm]. +extension CatalogTermPatterns on CatalogTerm { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _CatalogTerm value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _CatalogTerm() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _CatalogTerm value) $default,){ +final _that = this; +switch (_that) { +case _CatalogTerm(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _CatalogTerm value)? $default,){ +final _that = this; +switch (_that) { +case _CatalogTerm() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String id, String nameDe, String nameEn)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _CatalogTerm() when $default != null: +return $default(_that.id,_that.nameDe,_that.nameEn);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String id, String nameDe, String nameEn) $default,) {final _that = this; +switch (_that) { +case _CatalogTerm(): +return $default(_that.id,_that.nameDe,_that.nameEn);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String id, String nameDe, String nameEn)? $default,) {final _that = this; +switch (_that) { +case _CatalogTerm() when $default != null: +return $default(_that.id,_that.nameDe,_that.nameEn);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _CatalogTerm extends CatalogTerm { + const _CatalogTerm({required this.id, required this.nameDe, required this.nameEn}): super._(); + + +@override final String id; +@override final String nameDe; +@override final String nameEn; + +/// Create a copy of CatalogTerm +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$CatalogTermCopyWith<_CatalogTerm> get copyWith => __$CatalogTermCopyWithImpl<_CatalogTerm>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _CatalogTerm&&(identical(other.id, id) || other.id == id)&&(identical(other.nameDe, nameDe) || other.nameDe == nameDe)&&(identical(other.nameEn, nameEn) || other.nameEn == nameEn)); +} + + +@override +int get hashCode { + return Object.hash(runtimeType,id,nameDe,nameEn); +} + +@override +String toString() { + return 'CatalogTerm(id: $id, nameDe: $nameDe, nameEn: $nameEn)'; +} + + +} + +/// @nodoc +abstract mixin class _$CatalogTermCopyWith<$Res> implements $CatalogTermCopyWith<$Res> { + factory _$CatalogTermCopyWith(_CatalogTerm value, $Res Function(_CatalogTerm) _then) = __$CatalogTermCopyWithImpl; +@override @useResult +$Res call({ + String id, String nameDe, String nameEn +}); + + + + +} +/// @nodoc +class __$CatalogTermCopyWithImpl<$Res> + implements _$CatalogTermCopyWith<$Res> { + __$CatalogTermCopyWithImpl(this._self, this._then); + + final _CatalogTerm _self; + final $Res Function(_CatalogTerm) _then; + +/// Create a copy of CatalogTerm +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? nameDe = null,Object? nameEn = null,}) { + return _then(_CatalogTerm( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,nameDe: null == nameDe ? _self.nameDe : nameDe // ignore: cast_nullable_to_non_nullable +as String,nameEn: null == nameEn ? _self.nameEn : nameEn // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + +// dart format on diff --git a/app/lib/features/catalog/presentation/catalog_query_params.dart b/app/lib/features/catalog/presentation/catalog_query_params.dart new file mode 100644 index 0000000..979230a --- /dev/null +++ b/app/lib/features/catalog/presentation/catalog_query_params.dart @@ -0,0 +1,90 @@ +import 'package:shuffle/features/catalog/domain/catalog_search_query.dart'; + +/// Query parameter names of `/catalog` (Spec #3 § 5.5). +abstract final class CatalogQueryParams { + static const text = 'q'; + static const players = 'players'; + static const maxMinutes = 'maxMinutes'; + static const age = 'age'; + static const genre = 'genre'; + static const mechanic = 'mechanic'; +} + +final _uuid = RegExp( + r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-' + r'[0-9a-fA-F]{12}$', +); + +/// Reads a search from URL parameters. Invalid or unknown values are ignored +/// silently (Spec #3 § 5.5): numbers must be allowed values, IDs must look +/// like UUIDs, at most 20 IDs per kind. +CatalogSearchQuery parseCatalogQuery(Map params) { + int? number(String key, bool Function(int) allowed) { + final value = int.tryParse(params[key] ?? ''); + return value != null && allowed(value) ? value : null; + } + + Set ids(String key) { + final raw = params[key]; + if (raw == null || raw.isEmpty) { + return const {}; + } + final result = {}; + for (final part in raw.split(',')) { + final id = part.trim().toLowerCase(); + if (_uuid.hasMatch(id) && result.length < CatalogSearchLimits.maxTerms) { + result.add(id); + } + } + return result; + } + + return CatalogSearchQuery( + text: CatalogSearchQuery.cleanText(params[CatalogQueryParams.text] ?? ''), + filters: CatalogFilters( + players: number( + CatalogQueryParams.players, + (v) => + v >= CatalogSearchLimits.minPlayers && + v <= CatalogSearchLimits.maxPlayers, + ), + maxMinutes: number( + CatalogQueryParams.maxMinutes, + CatalogSearchLimits.maxMinutesSteps.contains, + ), + minAgeSuitable: number( + CatalogQueryParams.age, + CatalogSearchLimits.ageSteps.contains, + ), + genreIds: ids(CatalogQueryParams.genre), + mechanicIds: ids(CatalogQueryParams.mechanic), + ), + ); +} + +/// The URL parameters of [query]; only set values (inverse of +/// [parseCatalogQuery]). +Map catalogQueryParameters(CatalogSearchQuery query) { + final filters = query.filters; + return { + if (query.text.isNotEmpty) CatalogQueryParams.text: query.text, + if (filters.players != null) + CatalogQueryParams.players: '${filters.players}', + if (filters.maxMinutes != null) + CatalogQueryParams.maxMinutes: '${filters.maxMinutes}', + if (filters.minAgeSuitable != null) + CatalogQueryParams.age: '${filters.minAgeSuitable}', + if (filters.genreIds.isNotEmpty) + CatalogQueryParams.genre: (filters.genreIds.toList()..sort()).join(','), + if (filters.mechanicIds.isNotEmpty) + CatalogQueryParams.mechanic: (filters.mechanicIds.toList()..sort()).join( + ',', + ), + }; +} + +/// The `/catalog` location (path plus query) of [query]. +Uri catalogLocation(String path, CatalogSearchQuery query) { + final params = catalogQueryParameters(query); + return Uri(path: path, queryParameters: params.isEmpty ? null : params); +} diff --git a/app/lib/features/catalog/presentation/catalog_routes.dart b/app/lib/features/catalog/presentation/catalog_routes.dart new file mode 100644 index 0000000..00642e3 --- /dev/null +++ b/app/lib/features/catalog/presentation/catalog_routes.dart @@ -0,0 +1,14 @@ +import 'package:go_router/go_router.dart'; +import 'package:shuffle/core/routing/app_routes.dart'; +import 'package:shuffle/features/catalog/presentation/catalog_query_params.dart'; +import 'package:shuffle/features/catalog/presentation/search/catalog_search_screen.dart'; + +/// Routes of the catalog (Spec #3 § 5.5). `/catalog` is public. +final List catalogRoutes = [ + GoRoute( + path: AppRoutes.catalog, + builder: (context, state) => CatalogSearchScreen( + initialQuery: parseCatalogQuery(state.uri.queryParameters), + ), + ), +]; diff --git a/app/lib/features/catalog/presentation/providers/catalog_providers.dart b/app/lib/features/catalog/presentation/providers/catalog_providers.dart new file mode 100644 index 0000000..e716a9b --- /dev/null +++ b/app/lib/features/catalog/presentation/providers/catalog_providers.dart @@ -0,0 +1,28 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:shuffle/core/supabase/supabase_client_provider.dart'; +import 'package:shuffle/features/catalog/data/supabase_catalog_repository.dart'; +import 'package:shuffle/features/catalog/domain/catalog_repository.dart'; +import 'package:shuffle/features/catalog/domain/catalog_term.dart'; + +part 'catalog_providers.g.dart'; + +Duration? _noRetry(int retryCount, Object error) => null; + +@Riverpod(keepAlive: true) +CatalogRepository catalogRepository(Ref ref) => + SupabaseCatalogRepository(ref.watch(supabaseClientProvider)); + +/// Genres for the filter. Without automatic retry: the filter area offers an +/// explicit "try again" and the rest of the search stays usable. +@Riverpod(keepAlive: true, retry: _noRetry) +Future> catalogGenres(Ref ref) async { + final result = await ref.watch(catalogRepositoryProvider).fetchGenres(); + return result.when(success: (terms) => terms, failure: (f) => throw f); +} + +/// Mechanics for the filter, see [catalogGenres]. +@Riverpod(keepAlive: true, retry: _noRetry) +Future> catalogMechanics(Ref ref) async { + final result = await ref.watch(catalogRepositoryProvider).fetchMechanics(); + return result.when(success: (terms) => terms, failure: (f) => throw f); +} diff --git a/app/lib/features/catalog/presentation/providers/catalog_providers.g.dart b/app/lib/features/catalog/presentation/providers/catalog_providers.g.dart new file mode 100644 index 0000000..3bea0db --- /dev/null +++ b/app/lib/features/catalog/presentation/providers/catalog_providers.g.dart @@ -0,0 +1,152 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'catalog_providers.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(catalogRepository) +final catalogRepositoryProvider = CatalogRepositoryProvider._(); + +final class CatalogRepositoryProvider + extends + $FunctionalProvider< + CatalogRepository, + CatalogRepository, + CatalogRepository + > + with $Provider { + CatalogRepositoryProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'catalogRepositoryProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$catalogRepositoryHash(); + + @$internal + @override + $ProviderElement $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); + + @override + CatalogRepository create(Ref ref) { + return catalogRepository(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(CatalogRepository value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$catalogRepositoryHash() => r'20e0557d699468ce4d1db2a740352fdab045cc9e'; + +/// Genres for the filter. Without automatic retry: the filter area offers an +/// explicit "try again" and the rest of the search stays usable. + +@ProviderFor(catalogGenres) +final catalogGenresProvider = CatalogGenresProvider._(); + +/// Genres for the filter. Without automatic retry: the filter area offers an +/// explicit "try again" and the rest of the search stays usable. + +final class CatalogGenresProvider + extends + $FunctionalProvider< + AsyncValue>, + List, + FutureOr> + > + with + $FutureModifier>, + $FutureProvider> { + /// Genres for the filter. Without automatic retry: the filter area offers an + /// explicit "try again" and the rest of the search stays usable. + CatalogGenresProvider._() + : super( + from: null, + argument: null, + retry: _noRetry, + name: r'catalogGenresProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$catalogGenresHash(); + + @$internal + @override + $FutureProviderElement> $createElement( + $ProviderPointer pointer, + ) => $FutureProviderElement(pointer); + + @override + FutureOr> create(Ref ref) { + return catalogGenres(ref); + } +} + +String _$catalogGenresHash() => r'c04134a33952ff3d30818d557ed03418447cdd25'; + +/// Mechanics for the filter, see [catalogGenres]. + +@ProviderFor(catalogMechanics) +final catalogMechanicsProvider = CatalogMechanicsProvider._(); + +/// Mechanics for the filter, see [catalogGenres]. + +final class CatalogMechanicsProvider + extends + $FunctionalProvider< + AsyncValue>, + List, + FutureOr> + > + with + $FutureModifier>, + $FutureProvider> { + /// Mechanics for the filter, see [catalogGenres]. + CatalogMechanicsProvider._() + : super( + from: null, + argument: null, + retry: _noRetry, + name: r'catalogMechanicsProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$catalogMechanicsHash(); + + @$internal + @override + $FutureProviderElement> $createElement( + $ProviderPointer pointer, + ) => $FutureProviderElement(pointer); + + @override + FutureOr> create(Ref ref) { + return catalogMechanics(ref); + } +} + +String _$catalogMechanicsHash() => r'4a7b95bb44d3aa59748754e55b4802b9bf16c44c'; diff --git a/app/lib/features/catalog/presentation/search/catalog_search_controller.dart b/app/lib/features/catalog/presentation/search/catalog_search_controller.dart new file mode 100644 index 0000000..4b6b78a --- /dev/null +++ b/app/lib/features/catalog/presentation/search/catalog_search_controller.dart @@ -0,0 +1,171 @@ +import 'dart:async'; + +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:shuffle/features/catalog/domain/catalog_search_query.dart'; +import 'package:shuffle/features/catalog/presentation/providers/catalog_providers.dart'; +import 'package:shuffle/features/catalog/presentation/search/catalog_search_state.dart'; + +part 'catalog_search_controller.g.dart'; + +/// Debounce between the last keystroke and the search (Spec #3 § 5.4). +const catalogSearchDebounce = Duration(milliseconds: 300); + +/// Logic of the catalog search: debounce, only the latest request counts, +/// paging, retry (KAT-01 criteria 1, 4, 5, 8). +@riverpod +class CatalogSearchController extends _$CatalogSearchController { + Timer? _debounce; + int _requestId = 0; + bool _initialized = false; + + /// The effective query of the latest started first-page search, to skip + /// searches that would repeat it. + CatalogSearchQuery? _lastRequested; + + @override + CatalogSearchState build() { + ref.onDispose(() => _debounce?.cancel()); + return const CatalogSearchState(); + } + + /// Opens the screen with [initial] (from the URL) and searches right away. + /// Does nothing when called again. + void init(CatalogSearchQuery initial) { + if (_initialized) { + return; + } + _initialized = true; + state = state.copyWith(query: initial); + unawaited(_search()); + } + + /// Text changed: search after the debounce. A text below the minimum + /// length counts as "no search term". + void setText(String raw) { + final text = CatalogSearchQuery.cleanText(raw); + if (text == state.query.text) { + return; + } + state = state.copyWith(query: state.query.copyWith(text: text)); + _debounce?.cancel(); + _debounce = Timer(catalogSearchDebounce, () => unawaited(_search())); + } + + /// Enter in the search field: search now, without debounce. + void submitText(String raw) { + _debounce?.cancel(); + final text = CatalogSearchQuery.cleanText(raw); + state = state.copyWith(query: state.query.copyWith(text: text)); + unawaited(_search(force: true)); + } + + /// Filters changed: search now (a pending debounce is executed with it). + void setFilters(CatalogFilters filters) { + _debounce?.cancel(); + state = state.copyWith(query: state.query.copyWith(filters: filters)); + unawaited(_search()); + } + + void clearFilters() => setFilters(const CatalogFilters()); + + /// Loads the next page of the shown results. + Future loadMore() async { + if (state.status != CatalogSearchStatus.data || + !state.hasMore || + state.loadingMore || + state.refreshing) { + return; + } + final id = ++_requestId; + final offset = state.items.length; + state = state.copyWith(loadingMore: true, loadMoreFailure: null); + final result = await ref + .read(catalogRepositoryProvider) + .search(state.searchedQuery, offset: offset); + if (!ref.mounted || id != _requestId) { + return; + } + result.when( + success: (page) { + final known = {for (final item in state.items) item.id}; + final added = [ + for (final item in page.items) + if (known.add(item.id)) item, + ]; + final items = [...state.items, ...added]; + state = state.copyWith( + items: items, + // An empty or fully repeated page ends the list, so automatic + // loading cannot spin on the same offset. + totalCount: added.isEmpty ? items.length : page.totalCount, + loadingMore: false, + ); + }, + failure: (failure) { + state = state.copyWith(loadingMore: false, loadMoreFailure: failure); + }, + ); + } + + /// Repeats the last failed action (first page or next page). + Future retry() async { + if (state.status == CatalogSearchStatus.error) { + await _search(force: true); + } else if (state.loadMoreFailure != null) { + state = state.copyWith(loadMoreFailure: null); + await loadMore(); + } + } + + Future _search({bool force = false}) async { + final query = state.query; + final effective = query.effective; + if (!force && + _lastRequested == effective && + state.status != CatalogSearchStatus.error) { + return; + } + _lastRequested = effective; + final id = ++_requestId; + final hasItems = state.items.isNotEmpty; + state = state.copyWith( + status: hasItems ? state.status : CatalogSearchStatus.loading, + refreshing: hasItems, + failure: null, + loadingMore: false, + loadMoreFailure: null, + ); + final result = await ref + .read(catalogRepositoryProvider) + .search(effective, offset: 0); + if (!ref.mounted || id != _requestId) { + return; + } + result.when( + success: (page) { + state = state.copyWith( + status: page.items.isEmpty + ? CatalogSearchStatus.empty + : CatalogSearchStatus.data, + items: page.items, + totalCount: page.totalCount, + searchedQuery: query, + refreshing: false, + failure: null, + searchGeneration: state.searchGeneration + 1, + ); + }, + failure: (failure) { + // Keep the entered query so the user can retry. + _lastRequested = null; + state = state.copyWith( + status: CatalogSearchStatus.error, + items: const [], + totalCount: 0, + refreshing: false, + failure: failure, + ); + }, + ); + } +} 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 new file mode 100644 index 0000000..692cead --- /dev/null +++ b/app/lib/features/catalog/presentation/search/catalog_search_controller.g.dart @@ -0,0 +1,72 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'catalog_search_controller.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning +/// Logic of the catalog search: debounce, only the latest request counts, +/// paging, retry (KAT-01 criteria 1, 4, 5, 8). + +@ProviderFor(CatalogSearchController) +final catalogSearchControllerProvider = CatalogSearchControllerProvider._(); + +/// Logic of the catalog search: debounce, only the latest request counts, +/// paging, retry (KAT-01 criteria 1, 4, 5, 8). +final class CatalogSearchControllerProvider + extends $NotifierProvider { + /// Logic of the catalog search: debounce, only the latest request counts, + /// paging, retry (KAT-01 criteria 1, 4, 5, 8). + CatalogSearchControllerProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'catalogSearchControllerProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$catalogSearchControllerHash(); + + @$internal + @override + CatalogSearchController create() => CatalogSearchController(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(CatalogSearchState value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$catalogSearchControllerHash() => + r'b59678c9b9948ebe8bbfedce7faa428ffff34af1'; + +/// Logic of the catalog search: debounce, only the latest request counts, +/// paging, retry (KAT-01 criteria 1, 4, 5, 8). + +abstract class _$CatalogSearchController extends $Notifier { + CatalogSearchState build(); + @$mustCallSuper + @override + WhenComplete runBuild() { + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + CatalogSearchState, + Object?, + Object? + >; + return element.handleCreate(ref, build); + } +} diff --git a/app/lib/features/catalog/presentation/search/catalog_search_screen.dart b/app/lib/features/catalog/presentation/search/catalog_search_screen.dart new file mode 100644 index 0000000..d1e0940 --- /dev/null +++ b/app/lib/features/catalog/presentation/search/catalog_search_screen.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:shuffle/core/l10n/l10n.dart'; +import 'package:shuffle/core/routing/app_routes.dart'; +import 'package:shuffle/features/catalog/domain/catalog_search_query.dart'; +import 'package:shuffle/features/catalog/presentation/search/catalog_search_view.dart'; + +/// Screen of the catalog search (`/catalog`, public, Spec #3 § 5.5). +class CatalogSearchScreen extends StatelessWidget { + const CatalogSearchScreen({ + required this.initialQuery, + super.key, + this.onReportGame, + }); + + /// Start values from the URL. + final CatalogSearchQuery initialQuery; + + /// Entry point to report a missing game (KAT-04, #13); `null` in #3. + final ValueChanged? onReportGame; + + @override + Widget build(BuildContext context) { + return Scaffold( + key: const Key('catalog_search_screen'), + appBar: AppBar( + leading: BackButton( + onPressed: () => + context.canPop() ? context.pop() : context.go(AppRoutes.home), + ), + title: Text(context.l10n.catalogSearchTitle), + ), + body: CatalogSearchView( + initialQuery: initialQuery, + onReportGame: onReportGame, + syncUrl: true, + ), + ); + } +} diff --git a/app/lib/features/catalog/presentation/search/catalog_search_state.dart b/app/lib/features/catalog/presentation/search/catalog_search_state.dart new file mode 100644 index 0000000..deb89ed --- /dev/null +++ b/app/lib/features/catalog/presentation/search/catalog_search_state.dart @@ -0,0 +1,38 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:shuffle/features/catalog/domain/catalog_failure.dart'; +import 'package:shuffle/features/catalog/domain/catalog_game_summary.dart'; +import 'package:shuffle/features/catalog/domain/catalog_search_query.dart'; + +part 'catalog_search_state.freezed.dart'; + +enum CatalogSearchStatus { loading, data, empty, error } + +/// State of the catalog search (Spec #3 § 5.4). +@freezed +abstract class CatalogSearchState with _$CatalogSearchState { + const factory CatalogSearchState({ + /// What the user entered. May be ahead of the shown results while a + /// search is debounced or running. + @Default(CatalogSearchQuery()) CatalogSearchQuery query, + + /// The query the shown results belong to. + @Default(CatalogSearchQuery()) CatalogSearchQuery searchedQuery, + @Default(CatalogSearchStatus.loading) CatalogSearchStatus status, + @Default([]) List items, + @Default(0) int totalCount, + CatalogFailure? failure, + + /// A new search runs while the old results stay visible. + @Default(false) bool refreshing, + @Default(false) bool loadingMore, + CatalogFailure? loadMoreFailure, + + /// Counts completed first-page searches (announcements, URL sync). + @Default(0) int searchGeneration, + }) = _CatalogSearchState; + + const CatalogSearchState._(); + + bool get hasMore => + items.length < totalCount && items.length < CatalogSearchLimits.maxOffset; +} diff --git a/app/lib/features/catalog/presentation/search/catalog_search_state.freezed.dart b/app/lib/features/catalog/presentation/search/catalog_search_state.freezed.dart new file mode 100644 index 0000000..5a3b690 --- /dev/null +++ b/app/lib/features/catalog/presentation/search/catalog_search_state.freezed.dart @@ -0,0 +1,406 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint, type=warning, deprecated_member_use, deprecated_member_use_from_same_package +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'catalog_search_state.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$CatalogSearchState { + +/// What the user entered. May be ahead of the shown results while a +/// search is debounced or running. + CatalogSearchQuery get query;/// The query the shown results belong to. + CatalogSearchQuery get searchedQuery; CatalogSearchStatus get status; List get items; int get totalCount; CatalogFailure? get failure;/// A new search runs while the old results stay visible. + bool get refreshing; bool get loadingMore; CatalogFailure? get loadMoreFailure;/// Counts completed first-page searches (announcements, URL sync). + int get searchGeneration; +/// Create a copy of CatalogSearchState +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$CatalogSearchStateCopyWith get copyWith => _$CatalogSearchStateCopyWithImpl(this as CatalogSearchState, _$identity); + + + +@override +bool operator ==(Object other) { + final _this = this as CatalogSearchState; + return identical(this, other) || (other.runtimeType == runtimeType&&other is CatalogSearchState&&(identical(other.query, _this.query) || other.query == _this.query)&&(identical(other.searchedQuery, _this.searchedQuery) || other.searchedQuery == _this.searchedQuery)&&(identical(other.status, _this.status) || other.status == _this.status)&&const DeepCollectionEquality().equals(other.items, _this.items)&&(identical(other.totalCount, _this.totalCount) || other.totalCount == _this.totalCount)&&(identical(other.failure, _this.failure) || other.failure == _this.failure)&&(identical(other.refreshing, _this.refreshing) || other.refreshing == _this.refreshing)&&(identical(other.loadingMore, _this.loadingMore) || other.loadingMore == _this.loadingMore)&&(identical(other.loadMoreFailure, _this.loadMoreFailure) || other.loadMoreFailure == _this.loadMoreFailure)&&(identical(other.searchGeneration, _this.searchGeneration) || other.searchGeneration == _this.searchGeneration)); +} + + +@override +int get hashCode { + final _this = this as CatalogSearchState; + return Object.hash(runtimeType,_this.query,_this.searchedQuery,_this.status,const DeepCollectionEquality().hash(_this.items),_this.totalCount,_this.failure,_this.refreshing,_this.loadingMore,_this.loadMoreFailure,_this.searchGeneration); +} + +@override +String toString() { + final _this = this as CatalogSearchState; + return 'CatalogSearchState(query: ${_this.query}, searchedQuery: ${_this.searchedQuery}, status: ${_this.status}, items: ${_this.items}, totalCount: ${_this.totalCount}, failure: ${_this.failure}, refreshing: ${_this.refreshing}, loadingMore: ${_this.loadingMore}, loadMoreFailure: ${_this.loadMoreFailure}, searchGeneration: ${_this.searchGeneration})'; +} + + +} + +/// @nodoc +abstract mixin class $CatalogSearchStateCopyWith<$Res> { + factory $CatalogSearchStateCopyWith(CatalogSearchState value, $Res Function(CatalogSearchState) _then) = _$CatalogSearchStateCopyWithImpl; +@useResult +$Res call({ + CatalogSearchQuery query, CatalogSearchQuery searchedQuery, CatalogSearchStatus status, List items, int totalCount, CatalogFailure? failure, bool refreshing, bool loadingMore, CatalogFailure? loadMoreFailure, int searchGeneration +}); + + +$CatalogSearchQueryCopyWith<$Res> get query;$CatalogSearchQueryCopyWith<$Res> get searchedQuery;$CatalogFailureCopyWith<$Res>? get failure;$CatalogFailureCopyWith<$Res>? get loadMoreFailure; + +} +/// @nodoc +class _$CatalogSearchStateCopyWithImpl<$Res> + implements $CatalogSearchStateCopyWith<$Res> { + _$CatalogSearchStateCopyWithImpl(this._self, this._then); + + final CatalogSearchState _self; + final $Res Function(CatalogSearchState) _then; + +/// Create a copy of CatalogSearchState +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? query = null,Object? searchedQuery = null,Object? status = null,Object? items = null,Object? totalCount = null,Object? failure = freezed,Object? refreshing = null,Object? loadingMore = null,Object? loadMoreFailure = freezed,Object? searchGeneration = null,}) { + return _then(CatalogSearchState( +query: null == query ? _self.query : query // ignore: cast_nullable_to_non_nullable +as CatalogSearchQuery,searchedQuery: null == searchedQuery ? _self.searchedQuery : searchedQuery // ignore: cast_nullable_to_non_nullable +as CatalogSearchQuery,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable +as CatalogSearchStatus,items: null == items ? _self.items : items // ignore: cast_nullable_to_non_nullable +as List,totalCount: null == totalCount ? _self.totalCount : totalCount // ignore: cast_nullable_to_non_nullable +as int,failure: freezed == failure ? _self.failure : failure // ignore: cast_nullable_to_non_nullable +as CatalogFailure?,refreshing: null == refreshing ? _self.refreshing : refreshing // ignore: cast_nullable_to_non_nullable +as bool,loadingMore: null == loadingMore ? _self.loadingMore : loadingMore // ignore: cast_nullable_to_non_nullable +as bool,loadMoreFailure: freezed == loadMoreFailure ? _self.loadMoreFailure : loadMoreFailure // ignore: cast_nullable_to_non_nullable +as CatalogFailure?,searchGeneration: null == searchGeneration ? _self.searchGeneration : searchGeneration // ignore: cast_nullable_to_non_nullable +as int, + )); +} +/// Create a copy of CatalogSearchState +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$CatalogSearchQueryCopyWith<$Res> get query { + + return $CatalogSearchQueryCopyWith<$Res>(_self.query, (value) { + return _then(_self.copyWith(query: value)); + }); +}/// Create a copy of CatalogSearchState +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$CatalogSearchQueryCopyWith<$Res> get searchedQuery { + + return $CatalogSearchQueryCopyWith<$Res>(_self.searchedQuery, (value) { + return _then(_self.copyWith(searchedQuery: value)); + }); +}/// Create a copy of CatalogSearchState +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$CatalogFailureCopyWith<$Res>? get failure { + if (_self.failure == null) { + return null; + } + + return $CatalogFailureCopyWith<$Res>(_self.failure!, (value) { + return _then(_self.copyWith(failure: value)); + }); +}/// Create a copy of CatalogSearchState +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$CatalogFailureCopyWith<$Res>? get loadMoreFailure { + if (_self.loadMoreFailure == null) { + return null; + } + + return $CatalogFailureCopyWith<$Res>(_self.loadMoreFailure!, (value) { + return _then(_self.copyWith(loadMoreFailure: value)); + }); +} +} + + +/// Adds pattern-matching-related methods to [CatalogSearchState]. +extension CatalogSearchStatePatterns on CatalogSearchState { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _CatalogSearchState value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _CatalogSearchState() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _CatalogSearchState value) $default,){ +final _that = this; +switch (_that) { +case _CatalogSearchState(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _CatalogSearchState value)? $default,){ +final _that = this; +switch (_that) { +case _CatalogSearchState() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( CatalogSearchQuery query, CatalogSearchQuery searchedQuery, CatalogSearchStatus status, List items, int totalCount, CatalogFailure? failure, bool refreshing, bool loadingMore, CatalogFailure? loadMoreFailure, int searchGeneration)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _CatalogSearchState() when $default != null: +return $default(_that.query,_that.searchedQuery,_that.status,_that.items,_that.totalCount,_that.failure,_that.refreshing,_that.loadingMore,_that.loadMoreFailure,_that.searchGeneration);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( CatalogSearchQuery query, CatalogSearchQuery searchedQuery, CatalogSearchStatus status, List items, int totalCount, CatalogFailure? failure, bool refreshing, bool loadingMore, CatalogFailure? loadMoreFailure, int searchGeneration) $default,) {final _that = this; +switch (_that) { +case _CatalogSearchState(): +return $default(_that.query,_that.searchedQuery,_that.status,_that.items,_that.totalCount,_that.failure,_that.refreshing,_that.loadingMore,_that.loadMoreFailure,_that.searchGeneration);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( CatalogSearchQuery query, CatalogSearchQuery searchedQuery, CatalogSearchStatus status, List items, int totalCount, CatalogFailure? failure, bool refreshing, bool loadingMore, CatalogFailure? loadMoreFailure, int searchGeneration)? $default,) {final _that = this; +switch (_that) { +case _CatalogSearchState() when $default != null: +return $default(_that.query,_that.searchedQuery,_that.status,_that.items,_that.totalCount,_that.failure,_that.refreshing,_that.loadingMore,_that.loadMoreFailure,_that.searchGeneration);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _CatalogSearchState extends CatalogSearchState { + const _CatalogSearchState({this.query = const CatalogSearchQuery(), this.searchedQuery = const CatalogSearchQuery(), this.status = CatalogSearchStatus.loading, List items = const [], this.totalCount = 0, this.failure, this.refreshing = false, this.loadingMore = false, this.loadMoreFailure, this.searchGeneration = 0}): _items = items,super._(); + + +/// What the user entered. May be ahead of the shown results while a +/// search is debounced or running. +@override@JsonKey() final CatalogSearchQuery query; +/// The query the shown results belong to. +@override@JsonKey() final CatalogSearchQuery searchedQuery; +@override@JsonKey() final CatalogSearchStatus status; + final List _items; +@override@JsonKey() List get items { + if (_items is EqualUnmodifiableListView) return _items; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_items); +} + +@override@JsonKey() final int totalCount; +@override final CatalogFailure? failure; +/// A new search runs while the old results stay visible. +@override@JsonKey() final bool refreshing; +@override@JsonKey() final bool loadingMore; +@override final CatalogFailure? loadMoreFailure; +/// Counts completed first-page searches (announcements, URL sync). +@override@JsonKey() final int searchGeneration; + +/// Create a copy of CatalogSearchState +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$CatalogSearchStateCopyWith<_CatalogSearchState> get copyWith => __$CatalogSearchStateCopyWithImpl<_CatalogSearchState>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _CatalogSearchState&&(identical(other.query, query) || other.query == query)&&(identical(other.searchedQuery, searchedQuery) || other.searchedQuery == searchedQuery)&&(identical(other.status, status) || other.status == status)&&const DeepCollectionEquality().equals(other.items, _items)&&(identical(other.totalCount, totalCount) || other.totalCount == totalCount)&&(identical(other.failure, failure) || other.failure == failure)&&(identical(other.refreshing, refreshing) || other.refreshing == refreshing)&&(identical(other.loadingMore, loadingMore) || other.loadingMore == loadingMore)&&(identical(other.loadMoreFailure, loadMoreFailure) || other.loadMoreFailure == loadMoreFailure)&&(identical(other.searchGeneration, searchGeneration) || other.searchGeneration == searchGeneration)); +} + + +@override +int get hashCode { + return Object.hash(runtimeType,query,searchedQuery,status,const DeepCollectionEquality().hash(_items),totalCount,failure,refreshing,loadingMore,loadMoreFailure,searchGeneration); +} + +@override +String toString() { + return 'CatalogSearchState(query: $query, searchedQuery: $searchedQuery, status: $status, items: $items, totalCount: $totalCount, failure: $failure, refreshing: $refreshing, loadingMore: $loadingMore, loadMoreFailure: $loadMoreFailure, searchGeneration: $searchGeneration)'; +} + + +} + +/// @nodoc +abstract mixin class _$CatalogSearchStateCopyWith<$Res> implements $CatalogSearchStateCopyWith<$Res> { + factory _$CatalogSearchStateCopyWith(_CatalogSearchState value, $Res Function(_CatalogSearchState) _then) = __$CatalogSearchStateCopyWithImpl; +@override @useResult +$Res call({ + CatalogSearchQuery query, CatalogSearchQuery searchedQuery, CatalogSearchStatus status, List items, int totalCount, CatalogFailure? failure, bool refreshing, bool loadingMore, CatalogFailure? loadMoreFailure, int searchGeneration +}); + + +@override $CatalogSearchQueryCopyWith<$Res> get query;@override $CatalogSearchQueryCopyWith<$Res> get searchedQuery;@override $CatalogFailureCopyWith<$Res>? get failure;@override $CatalogFailureCopyWith<$Res>? get loadMoreFailure; + +} +/// @nodoc +class __$CatalogSearchStateCopyWithImpl<$Res> + implements _$CatalogSearchStateCopyWith<$Res> { + __$CatalogSearchStateCopyWithImpl(this._self, this._then); + + final _CatalogSearchState _self; + final $Res Function(_CatalogSearchState) _then; + +/// Create a copy of CatalogSearchState +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? query = null,Object? searchedQuery = null,Object? status = null,Object? items = null,Object? totalCount = null,Object? failure = freezed,Object? refreshing = null,Object? loadingMore = null,Object? loadMoreFailure = freezed,Object? searchGeneration = null,}) { + return _then(_CatalogSearchState( +query: null == query ? _self.query : query // ignore: cast_nullable_to_non_nullable +as CatalogSearchQuery,searchedQuery: null == searchedQuery ? _self.searchedQuery : searchedQuery // ignore: cast_nullable_to_non_nullable +as CatalogSearchQuery,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable +as CatalogSearchStatus,items: null == items ? _self._items : items // ignore: cast_nullable_to_non_nullable +as List,totalCount: null == totalCount ? _self.totalCount : totalCount // ignore: cast_nullable_to_non_nullable +as int,failure: freezed == failure ? _self.failure : failure // ignore: cast_nullable_to_non_nullable +as CatalogFailure?,refreshing: null == refreshing ? _self.refreshing : refreshing // ignore: cast_nullable_to_non_nullable +as bool,loadingMore: null == loadingMore ? _self.loadingMore : loadingMore // ignore: cast_nullable_to_non_nullable +as bool,loadMoreFailure: freezed == loadMoreFailure ? _self.loadMoreFailure : loadMoreFailure // ignore: cast_nullable_to_non_nullable +as CatalogFailure?,searchGeneration: null == searchGeneration ? _self.searchGeneration : searchGeneration // ignore: cast_nullable_to_non_nullable +as int, + )); +} + +/// Create a copy of CatalogSearchState +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$CatalogSearchQueryCopyWith<$Res> get query { + + return $CatalogSearchQueryCopyWith<$Res>(_self.query, (value) { + return _then(_self.copyWith(query: value)); + }); +}/// Create a copy of CatalogSearchState +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$CatalogSearchQueryCopyWith<$Res> get searchedQuery { + + return $CatalogSearchQueryCopyWith<$Res>(_self.searchedQuery, (value) { + return _then(_self.copyWith(searchedQuery: value)); + }); +}/// Create a copy of CatalogSearchState +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$CatalogFailureCopyWith<$Res>? get failure { + if (_self.failure == null) { + return null; + } + + return $CatalogFailureCopyWith<$Res>(_self.failure!, (value) { + return _then(_self.copyWith(failure: value)); + }); +}/// Create a copy of CatalogSearchState +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$CatalogFailureCopyWith<$Res>? get loadMoreFailure { + if (_self.loadMoreFailure == null) { + return null; + } + + return $CatalogFailureCopyWith<$Res>(_self.loadMoreFailure!, (value) { + return _then(_self.copyWith(loadMoreFailure: value)); + }); +} +} + +// dart format on diff --git a/app/lib/features/catalog/presentation/search/catalog_search_view.dart b/app/lib/features/catalog/presentation/search/catalog_search_view.dart new file mode 100644 index 0000000..4512c6d --- /dev/null +++ b/app/lib/features/catalog/presentation/search/catalog_search_view.dart @@ -0,0 +1,458 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shuffle/core/l10n/l10n.dart'; +import 'package:shuffle/core/routing/app_routes.dart'; +import 'package:shuffle/core/routing/url_replacer.dart'; +import 'package:shuffle/core/theme/app_spacing.dart'; +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/presentation/catalog_query_params.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'; +import 'package:shuffle/features/catalog/presentation/search/widgets/catalog_filter_panel.dart'; +import 'package:shuffle/features/catalog/presentation/search/widgets/catalog_game_tile.dart'; +import 'package:shuffle/features/catalog/presentation/search/widgets/catalog_search_field.dart'; + +/// Content of the catalog search: search field, filters, result list +/// (Spec #3 § 5.6). Kept separate from the screen so the start page without +/// sign-in (QUE-09, #29) can embed it. +class CatalogSearchView extends ConsumerStatefulWidget { + const CatalogSearchView({ + required this.initialQuery, + super.key, + this.onReportGame, + this.syncUrl = false, + this.autofocusSearch = kIsWeb, + }); + + /// Query the search starts with (from the URL). + final CatalogSearchQuery initialQuery; + + /// Entry point to report a missing game from the empty state (KAT-04, #13). + /// `null` hides the action; #3 passes `null`. + final ValueChanged? onReportGame; + + /// Writes the search into the browser URL after each search (web only). + final bool syncUrl; + + /// Focus the search field on open. Web only by default: on phones the + /// keyboard would cover the list. + final bool autofocusSearch; + + @override + ConsumerState createState() => _CatalogSearchViewState(); +} + +/// Max width of the result column on large screens. +const double _maxResultWidth = 720; +const double _sidebarWidth = 320; + +/// Load the next page when less than about five tiles are left below. +const double _autoLoadExtent = 500; + +/// Pause before announcing the hit count, so quick filter changes do not +/// queue up announcements. +const Duration _announceDelay = Duration(milliseconds: 600); + +class _CatalogSearchViewState extends ConsumerState { + final ScrollController _scroll = ScrollController(); + final FocusNode _newTileFocus = FocusNode(); + Timer? _announceTimer; + + /// Index of the first tile that came with "load more"; it takes focus. + int? _focusIndex; + bool _focusHandled = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + ref + .read(catalogSearchControllerProvider.notifier) + .init(widget.initialQuery); + } + }); + } + + @override + void dispose() { + _announceTimer?.cancel(); + _scroll.dispose(); + _newTileFocus.dispose(); + super.dispose(); + } + + CatalogSearchController get _controller => + ref.read(catalogSearchControllerProvider.notifier); + + void _onSearchCompleted() { + _focusIndex = null; + _focusHandled = false; + _announceTimer?.cancel(); + _announceTimer = Timer(_announceDelay, _announce); + if (widget.syncUrl) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) { + return; + } + final searched = ref + .read(catalogSearchControllerProvider) + .searchedQuery; + ref + .read(urlReplacerProvider) + .replace(context, catalogLocation(AppRoutes.catalog, searched)); + }); + } + } + + void _announce() { + if (!mounted) { + return; + } + final state = ref.read(catalogSearchControllerProvider); + if (state.refreshing || + (state.status != CatalogSearchStatus.data && + state.status != CatalogSearchStatus.empty)) { + return; + } + unawaited( + SemanticsService.sendAnnouncement( + View.of(context), + context.l10n.catalogSearchAnnouncement(state.totalCount), + Directionality.of(context), + ), + ); + } + + void _loadMoreByUser() { + final state = ref.read(catalogSearchControllerProvider); + _focusIndex = state.items.length; + _focusHandled = false; + if (state.loadMoreFailure != null) { + unawaited(_controller.retry()); + } else { + unawaited(_controller.loadMore()); + } + } + + /// Auto-loading when the end of the list is near (also when the first page + /// does not fill the viewport, so no scroll event can ever come). + void _checkAutoLoad() { + if (!mounted || !_scroll.hasClients) { + return; + } + final state = ref.read(catalogSearchControllerProvider); + if (state.status != CatalogSearchStatus.data || + !state.hasMore || + state.loadingMore || + state.refreshing || + state.loadMoreFailure != null) { + return; + } + if (_scroll.position.extentAfter < _autoLoadExtent) { + unawaited(_controller.loadMore()); + } + } + + void _scheduleAutoLoadCheck() { + WidgetsBinding.instance.addPostFrameCallback((_) => _checkAutoLoad()); + } + + void _scheduleFocus(CatalogSearchState state) { + final index = _focusIndex; + if (index == null || _focusHandled || state.items.length <= index) { + return; + } + _focusHandled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !_newTileFocus.canRequestFocus) { + return; + } + _newTileFocus.requestFocus(); + final focusContext = _newTileFocus.context; + if (focusContext != null) { + unawaited(Scrollable.ensureVisible(focusContext)); + } + }); + } + + Future _openFilters() { + final l10n = context.l10n; + return showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + showDragHandle: true, + builder: (sheetContext) => Consumer( + builder: (context, ref, _) { + final filters = ref.watch( + catalogSearchControllerProvider.select((s) => s.query.filters), + ); + return Column( + key: const Key('catalog_filter_sheet'), + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg), + child: Row( + children: [ + Expanded( + child: Semantics( + header: true, + child: Text( + l10n.catalogSearchFilterTitle, + style: Theme.of(context).textTheme.titleLarge, + ), + ), + ), + TextButton( + key: const Key('catalog_filter_done'), + onPressed: () => Navigator.of(sheetContext).pop(), + child: Text(l10n.catalogSearchFilterDone), + ), + ], + ), + ), + Flexible( + child: SingleChildScrollView( + padding: const EdgeInsets.all(AppSpacing.lg), + child: CatalogFilterPanel( + filters: filters, + onChanged: _controller.setFilters, + ), + ), + ), + ], + ); + }, + ), + ); + } + + @override + Widget build(BuildContext context) { + ref.listen( + catalogSearchControllerProvider.select((s) => s.searchGeneration), + (previous, next) => _onSearchCompleted(), + ); + final state = ref.watch(catalogSearchControllerProvider); + _scheduleFocus(state); + if (state.status == CatalogSearchStatus.data) { + _scheduleAutoLoadCheck(); + } + + return LayoutBuilder( + builder: (context, constraints) { + final width = constraints.maxWidth; + final expanded = AppWidthClass.of(width) == AppWidthClass.expanded; + final padding = AppSpacing.pagePadding(width); + final main = Align( + alignment: Alignment.topCenter, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: _maxResultWidth), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: EdgeInsets.fromLTRB( + padding.left, + padding.top, + padding.right, + AppSpacing.sm, + ), + child: _buildHeader(state, showFilterButton: !expanded), + ), + Expanded(child: _buildResults(state, padding)), + ], + ), + ), + ); + if (!expanded) { + return main; + } + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: _sidebarWidth, + child: SingleChildScrollView( + key: const Key('catalog_filter_sidebar'), + padding: EdgeInsets.all(padding.left), + child: _buildSidebar(state), + ), + ), + const VerticalDivider(width: 1), + Expanded(child: main), + ], + ); + }, + ); + } + + Widget _buildSidebar(CatalogSearchState state) { + final l10n = context.l10n; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Semantics( + header: true, + child: Text( + l10n.catalogSearchFilterTitle, + style: Theme.of(context).textTheme.titleLarge, + ), + ), + const SizedBox(height: AppSpacing.lg), + CatalogFilterPanel( + filters: state.query.filters, + onChanged: _controller.setFilters, + ), + ], + ); + } + + Widget _buildHeader( + CatalogSearchState state, { + required bool showFilterButton, + }) { + final l10n = context.l10n; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + CatalogSearchField( + initialText: widget.initialQuery.text, + autofocus: widget.autofocusSearch, + onChanged: _controller.setText, + onSubmitted: _controller.submitText, + ), + if (showFilterButton) ...[ + const SizedBox(height: AppSpacing.sm), + Align( + alignment: AlignmentDirectional.centerStart, + child: OutlinedButton.icon( + key: const Key('catalog_filter_button'), + onPressed: _openFilters, + icon: const Icon(Icons.tune), + label: Text( + l10n.catalogSearchFilterButton(state.query.activeFilterCount), + ), + ), + ), + ], + ], + ); + } + + Widget _buildResults(CatalogSearchState state, EdgeInsets padding) { + final l10n = context.l10n; + switch (state.status) { + case CatalogSearchStatus.loading: + return Center( + child: Semantics( + label: l10n.catalogSearchLoading, + liveRegion: true, + child: const CircularProgressIndicator(key: Key('catalog_loading')), + ), + ); + case CatalogSearchStatus.error: + final offline = state.failure is CatalogNetworkFailure; + return AppStateMessage( + key: const Key('catalog_error'), + icon: offline ? Icons.wifi_off : Icons.error_outline, + title: offline + ? l10n.catalogSearchErrorOffline + : l10n.catalogSearchErrorGeneric, + actions: [ + AppStateAction( + key: const Key('catalog_error_retry'), + label: l10n.catalogSearchRetry, + onPressed: () => unawaited(_controller.retry()), + ), + ], + ); + case CatalogSearchStatus.empty: + return CatalogEmptyState( + query: state.searchedQuery, + onResetFilters: _controller.clearFilters, + onReportGame: widget.onReportGame, + ); + case CatalogSearchStatus.data: + return _buildList(state, padding); + } + } + + Widget _buildList(CatalogSearchState state, EdgeInsets padding) { + final l10n = context.l10n; + final items = state.items; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (state.refreshing) + Semantics( + label: l10n.catalogSearchLoading, + child: const LinearProgressIndicator( + key: Key('catalog_refreshing'), + minHeight: 2, + ), + ), + Padding( + padding: EdgeInsets.symmetric(horizontal: padding.left), + child: Align( + alignment: AlignmentDirectional.centerStart, + child: Text( + l10n.catalogSearchResultCount(state.totalCount), + key: const Key('catalog_result_count'), + style: Theme.of(context).textTheme.labelLarge, + ), + ), + ), + Expanded( + child: NotificationListener( + onNotification: (notification) { + _checkAutoLoad(); + return false; + }, + child: ListView.builder( + key: const Key('catalog_result_list'), + controller: _scroll, + padding: EdgeInsets.symmetric( + horizontal: padding.left, + vertical: AppSpacing.sm, + ), + itemCount: items.length + 1, + itemBuilder: (context, index) { + if (index == items.length) { + return AppListFooter( + isLoading: state.loadingMore, + hasMore: state.hasMore, + errorMessage: state.loadMoreFailure != null + ? l10n.catalogSearchLoadMoreError + : null, + loadingLabel: l10n.catalogSearchLoading, + loadMoreLabel: l10n.catalogSearchLoadMore, + retryLabel: l10n.catalogSearchRetry, + onLoadMore: _loadMoreByUser, + onRetry: _loadMoreByUser, + ); + } + return CatalogGameTile( + key: ValueKey(items[index].id), + game: items[index], + focusNode: index == _focusIndex ? _newTileFocus : null, + ); + }, + ), + ), + ), + ], + ); + } +} diff --git a/app/lib/features/catalog/presentation/search/widgets/catalog_empty_state.dart b/app/lib/features/catalog/presentation/search/widgets/catalog_empty_state.dart new file mode 100644 index 0000000..86f8001 --- /dev/null +++ b/app/lib/features/catalog/presentation/search/widgets/catalog_empty_state.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; +import 'package:shuffle/core/l10n/l10n.dart'; +import 'package:shuffle/core/widgets/app_state_message.dart'; +import 'package:shuffle/features/catalog/domain/catalog_search_query.dart'; + +/// Empty list of the catalog search (Spec #3 § 5.6, KAT-01 criteria 6 and 8). +/// +/// [query] is the query the (empty) result belongs to. With a search text the +/// widget shows the hint and, if [onReportGame] is set, the entry point to +/// report a missing game (KAT-04 / #13 sets it; #3 passes `null`). +class CatalogEmptyState extends StatelessWidget { + const CatalogEmptyState({ + required this.query, + required this.onResetFilters, + super.key, + this.onReportGame, + }); + + final CatalogSearchQuery query; + final VoidCallback onResetFilters; + + /// Receives the search text. `null` hides the action. + final ValueChanged? onReportGame; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final reset = AppStateAction( + key: const Key('catalog_empty_reset'), + label: l10n.catalogSearchFilterReset, + onPressed: onResetFilters, + ); + final report = onReportGame; + + if (query.hasText) { + return AppStateMessage( + key: const Key('catalog_empty_query'), + icon: Icons.search_off, + title: l10n.catalogSearchEmptyForQuery(query.text), + message: l10n.catalogSearchEmptyForQueryHint, + actions: [ + if (query.hasFilters) reset, + if (report != null) + AppStateAction( + key: const Key('catalog_empty_report'), + label: l10n.catalogSearchReportGame, + onPressed: () => report(query.text), + ), + ], + ); + } + if (query.hasFilters) { + return AppStateMessage( + key: const Key('catalog_empty_filters'), + icon: Icons.filter_alt_off_outlined, + title: l10n.catalogSearchEmptyForFilters, + actions: [reset], + ); + } + return AppStateMessage( + key: const Key('catalog_empty_catalog'), + icon: Icons.inventory_2_outlined, + title: l10n.catalogSearchEmptyCatalog, + ); + } +} diff --git a/app/lib/features/catalog/presentation/search/widgets/catalog_filter_panel.dart b/app/lib/features/catalog/presentation/search/widgets/catalog_filter_panel.dart new file mode 100644 index 0000000..411a078 --- /dev/null +++ b/app/lib/features/catalog/presentation/search/widgets/catalog_filter_panel.dart @@ -0,0 +1,236 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shuffle/core/l10n/l10n.dart'; +import 'package:shuffle/core/theme/app_spacing.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/providers/catalog_providers.dart'; + +/// Filter area (Spec #3 § 5.6): player count, play time, age, genres and +/// mechanics, reset. Used in the bottom sheet (compact/medium) and the side +/// bar (expanded). Changes apply immediately through [onChanged]. +class CatalogFilterPanel extends ConsumerWidget { + const CatalogFilterPanel({ + required this.filters, + required this.onChanged, + super.key, + }); + + final CatalogFilters filters; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = context.l10n; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + _SingleChoice( + fieldKey: const Key('catalog_filter_players'), + label: l10n.catalogSearchFilterPlayers, + anyLabel: l10n.catalogSearchFilterAny, + value: filters.players, + options: [ + for ( + var n = CatalogSearchLimits.minPlayers; + n <= CatalogSearchLimits.maxPlayers; + n++ + ) + n, + ], + optionLabel: l10n.catalogSearchPlayersCount, + onChanged: (v) => onChanged(filters.copyWith(players: v)), + ), + const SizedBox(height: AppSpacing.lg), + _SingleChoice( + fieldKey: const Key('catalog_filter_duration'), + label: l10n.catalogSearchFilterDuration, + anyLabel: l10n.catalogSearchFilterAny, + value: filters.maxMinutes, + options: CatalogSearchLimits.maxMinutesSteps, + optionLabel: l10n.catalogSearchFilterDurationValue, + onChanged: (v) => onChanged(filters.copyWith(maxMinutes: v)), + ), + const SizedBox(height: AppSpacing.lg), + _SingleChoice( + fieldKey: const Key('catalog_filter_age'), + label: l10n.catalogSearchFilterAge, + anyLabel: l10n.catalogSearchFilterAny, + value: filters.minAgeSuitable, + options: CatalogSearchLimits.ageSteps, + optionLabel: l10n.catalogSearchFilterAgeValue, + onChanged: (v) => onChanged(filters.copyWith(minAgeSuitable: v)), + ), + const SizedBox(height: AppSpacing.lg), + _TermChoice( + sectionKey: const Key('catalog_filter_genres'), + label: l10n.catalogSearchFilterGenres, + terms: ref.watch(catalogGenresProvider), + onRetry: () => ref.invalidate(catalogGenresProvider), + selected: filters.genreIds, + onChanged: (ids) => onChanged(filters.copyWith(genreIds: ids)), + ), + const SizedBox(height: AppSpacing.lg), + _TermChoice( + sectionKey: const Key('catalog_filter_mechanics'), + label: l10n.catalogSearchFilterMechanics, + terms: ref.watch(catalogMechanicsProvider), + onRetry: () => ref.invalidate(catalogMechanicsProvider), + selected: filters.mechanicIds, + onChanged: (ids) => onChanged(filters.copyWith(mechanicIds: ids)), + ), + const SizedBox(height: AppSpacing.lg), + Align( + alignment: AlignmentDirectional.centerStart, + child: TextButton( + key: const Key('catalog_filter_reset'), + onPressed: filters.isEmpty + ? null + : () => onChanged(const CatalogFilters()), + child: Text(l10n.catalogSearchFilterReset), + ), + ), + ], + ); + } +} + +/// Single selection with "any" (drop-down, 48 dp, labeled). +class _SingleChoice extends StatelessWidget { + const _SingleChoice({ + required this.fieldKey, + required this.label, + required this.anyLabel, + required this.value, + required this.options, + required this.optionLabel, + required this.onChanged, + }); + + final Key fieldKey; + final String label; + final String anyLabel; + final int? value; + final List options; + final String Function(int) optionLabel; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return KeyedSubtree( + key: fieldKey, + child: DropdownButtonFormField( + key: ValueKey(value), + // The field keeps its own value; a new key follows outside changes + // (reset, URL). + initialValue: value, + isExpanded: true, + decoration: InputDecoration(labelText: label), + items: [ + DropdownMenuItem(child: Text(anyLabel)), + for (final option in options) + DropdownMenuItem( + value: option, + child: Text(optionLabel(option)), + ), + ], + onChanged: onChanged, + ), + ); + } +} + +/// Multi selection as filter chips, sorted by the localized name. +class _TermChoice extends StatelessWidget { + const _TermChoice({ + required this.sectionKey, + required this.label, + required this.terms, + required this.onRetry, + required this.selected, + required this.onChanged, + }); + + final Key sectionKey; + final String label; + final AsyncValue> terms; + final VoidCallback onRetry; + final Set selected; + final ValueChanged> onChanged; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final theme = Theme.of(context); + final language = Localizations.localeOf(context).languageCode; + + final Widget content = terms.when( + data: (list) { + final sorted = [...list] + ..sort( + (a, b) => a + .localizedName(language) + .toLowerCase() + .compareTo(b.localizedName(language).toLowerCase()), + ); + final full = selected.length >= CatalogSearchLimits.maxTerms; + return Wrap( + spacing: AppSpacing.sm, + runSpacing: AppSpacing.xs, + children: [ + for (final term in sorted) + FilterChip( + key: Key('catalog_term_${term.id}'), + label: Text(term.localizedName(language)), + selected: selected.contains(term.id), + onSelected: !selected.contains(term.id) && full + ? null + : (on) => onChanged( + on + ? {...selected, term.id} + : ({...selected}..remove(term.id)), + ), + ), + ], + ); + }, + loading: () => const Padding( + padding: EdgeInsets.all(AppSpacing.sm), + child: Center(child: CircularProgressIndicator()), + ), + error: (error, stack) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Semantics( + liveRegion: true, + child: Text( + l10n.catalogSearchTermsError, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.error, + ), + ), + ), + TextButton( + key: const Key('catalog_terms_retry'), + onPressed: onRetry, + child: Text(l10n.catalogSearchRetry), + ), + ], + ), + ); + + return Column( + key: sectionKey, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Semantics( + header: true, + child: Text(label, style: theme.textTheme.titleSmall), + ), + const SizedBox(height: AppSpacing.sm), + content, + ], + ); + } +} diff --git a/app/lib/features/catalog/presentation/search/widgets/catalog_game_tile.dart b/app/lib/features/catalog/presentation/search/widgets/catalog_game_tile.dart new file mode 100644 index 0000000..4cbda29 --- /dev/null +++ b/app/lib/features/catalog/presentation/search/widgets/catalog_game_tile.dart @@ -0,0 +1,180 @@ +import 'package:flutter/material.dart'; +import 'package:shuffle/core/l10n/l10n.dart'; +import 'package:shuffle/core/theme/app_spacing.dart'; +import 'package:shuffle/core/widgets/game_cover.dart'; +import 'package:shuffle/features/catalog/domain/catalog_game_summary.dart'; + +/// Result tile of the catalog search (Spec #3 § 5.6). +/// +/// Shows a [GameCover] placeholder (no image URL in #3), title, year, +/// publishers and short facts; missing facts are left out. [onTap] is `null` +/// in #3: the tile is then not a button (no dead link) but still focusable +/// programmatically ([focusNode]) so keyboard focus can move to the first +/// newly loaded tile. +class CatalogGameTile extends StatefulWidget { + const CatalogGameTile({ + required this.game, + super.key, + this.onTap, + this.focusNode, + }); + + final CatalogGameSummary game; + final VoidCallback? onTap; + final FocusNode? focusNode; + + /// Facts line, e.g. "2–4 Spieler · 30–60 Min. · ab 10" (missing parts are + /// left out). + static List facts(AppLocalizations l10n, CatalogGameSummary game) { + String? players() { + final min = game.minPlayers; + final max = game.maxPlayers; + if (min != null && max != null && min != max) { + return l10n.catalogSearchPlayersRange(min, max); + } + final single = min ?? max; + return single == null ? null : l10n.catalogSearchPlayersCount(single); + } + + String? minutes() { + final min = game.minMinutes; + final max = game.maxMinutes; + if (min != null && max != null && min != max) { + return l10n.catalogSearchMinutesRange(min, max); + } + final single = max ?? min; + return single == null ? null : l10n.catalogSearchMinutesSingle(single); + } + + final age = game.minAge; + return [ + ?players(), + ?minutes(), + if (age != null) l10n.catalogSearchMinAge(age), + ]; + } + + /// Up to two publishers, then "+n". + static String? publishers(AppLocalizations l10n, CatalogGameSummary game) { + final names = game.publisherNames; + if (names.isEmpty) { + return null; + } + final shown = names.take(2).join(', '); + final more = names.length - 2; + return more > 0 ? l10n.catalogSearchPublishersMore(shown, more) : shown; + } + + @override + State createState() => _CatalogGameTileState(); +} + +class _CatalogGameTileState extends State { + bool _focused = false; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final game = widget.game; + final facts = CatalogGameTile.facts(l10n, game); + final publishers = CatalogGameTile.publishers(l10n, game); + final subtitle = [ + if (game.year != null) '${game.year}', + ?publishers, + ].join(' · '); + final alt = game.matchedAltTitle; + + final label = [ + game.title, + if (subtitle.isNotEmpty) subtitle, + if (facts.isNotEmpty) facts.join(', '), + if (alt != null) l10n.catalogSearchAlsoKnownAs(alt), + ].join(', '); + + final body = Padding( + padding: const EdgeInsets.all(AppSpacing.md), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + GameCover(title: game.title, titleVisibleNearby: true), + const SizedBox(width: AppSpacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + game.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleMedium, + ), + if (alt != null) + Text( + l10n.catalogSearchAlsoKnownAs(alt), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith( + fontStyle: FontStyle.italic, + color: scheme.onSurfaceVariant, + ), + ), + if (subtitle.isNotEmpty) + Text( + subtitle, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + if (facts.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: AppSpacing.xs), + child: Text( + facts.join(' · '), + style: theme.textTheme.bodyMedium, + ), + ), + ], + ), + ), + ], + ), + ); + + final onTap = widget.onTap; + return Focus( + focusNode: widget.focusNode, + // Not a tab stop while the tile does nothing, but focusable by code. + skipTraversal: onTap == null, + onFocusChange: (focused) => setState(() => _focused = focused), + child: Semantics( + container: true, + button: onTap != null, + label: label, + onTap: onTap, + child: ExcludeSemantics( + child: Card( + key: Key('catalog_tile_${game.id}'), + margin: const EdgeInsets.symmetric(vertical: AppSpacing.xs), + shape: RoundedRectangleBorder( + borderRadius: AppRadius.mdAll, + side: _focused + ? BorderSide(color: scheme.primary, width: 3) + : BorderSide(color: scheme.outlineVariant), + ), + child: onTap == null + ? body + : InkWell( + borderRadius: AppRadius.mdAll, + onTap: onTap, + child: body, + ), + ), + ), + ), + ); + } +} diff --git a/app/lib/features/catalog/presentation/search/widgets/catalog_search_field.dart b/app/lib/features/catalog/presentation/search/widgets/catalog_search_field.dart new file mode 100644 index 0000000..8ec5a14 --- /dev/null +++ b/app/lib/features/catalog/presentation/search/widgets/catalog_search_field.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:shuffle/core/l10n/l10n.dart'; +import 'package:shuffle/core/widgets/app_text_field.dart'; +import 'package:shuffle/features/catalog/domain/catalog_search_query.dart'; + +/// Search field of the catalog search (Spec #3 § 5.6): visible label and +/// hint, search keyboard action, hard limit of 100 characters without +/// counter, clear button (48 dp). +class CatalogSearchField extends StatefulWidget { + const CatalogSearchField({ + required this.initialText, + required this.onChanged, + required this.onSubmitted, + super.key, + this.autofocus = false, + }); + + final String initialText; + final bool autofocus; + + /// Called on every edit. + final ValueChanged onChanged; + + /// Called on Enter and when the text is cleared: search without debounce. + final ValueChanged onSubmitted; + + @override + State createState() => _CatalogSearchFieldState(); +} + +class _CatalogSearchFieldState extends State { + late final TextEditingController _controller = TextEditingController( + text: widget.initialText, + ); + final FocusNode _focusNode = FocusNode(); + + @override + void dispose() { + _controller.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + void _clear() { + _controller.clear(); + widget.onSubmitted(''); + _focusNode.requestFocus(); + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return AppTextField( + key: const Key('catalog_search_field'), + label: l10n.catalogSearchTitle, + helperText: l10n.catalogSearchFieldHint, + controller: _controller, + focusNode: _focusNode, + autofocus: widget.autofocus, + textInputAction: TextInputAction.search, + keyboardType: TextInputType.text, + autocorrect: false, + enableSuggestions: false, + inputFormatters: [ + LengthLimitingTextInputFormatter(CatalogSearchLimits.maxTextLength), + ], + onChanged: widget.onChanged, + onSubmitted: widget.onSubmitted, + suffixIcon: ValueListenableBuilder( + valueListenable: _controller, + builder: (context, value, _) => value.text.isEmpty + ? const SizedBox.shrink() + : IconButton( + key: const Key('catalog_search_clear'), + tooltip: l10n.catalogSearchClear, + icon: const Icon(Icons.close), + onPressed: _clear, + ), + ), + ); + } +} 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 new file mode 100644 index 0000000..e241a40 --- /dev/null +++ b/app/lib/features/catalog/presentation/widgets/catalog_search_entry_button.dart @@ -0,0 +1,20 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:shuffle/core/l10n/l10n.dart'; +import 'package:shuffle/core/routing/app_routes.dart'; + +/// Entry from the start page to the catalog search (Spec #3 § 5.5). The start +/// page without sign-in (QUE-09, #29) replaces this with the search itself. +class CatalogSearchEntryButton extends StatelessWidget { + const CatalogSearchEntryButton({super.key}); + + @override + Widget build(BuildContext context) { + return IconButton( + key: const Key('home_catalog_search_button'), + tooltip: context.l10n.catalogSearchTitle, + icon: const Icon(Icons.search), + onPressed: () => context.go(AppRoutes.catalog), + ); + } +} diff --git a/app/lib/features/home/presentation/home_screen.dart b/app/lib/features/home/presentation/home_screen.dart index 1274e26..e26c4a9 100644 --- a/app/lib/features/home/presentation/home_screen.dart +++ b/app/lib/features/home/presentation/home_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:shuffle/core/l10n/l10n.dart'; +import 'package:shuffle/features/catalog/presentation/widgets/catalog_search_entry_button.dart'; /// Platzhalter-Startseite. Wird durch die Katalogsuche bzw. QUE-09 ersetzt. class HomeScreen extends StatelessWidget { @@ -10,7 +11,10 @@ class HomeScreen extends StatelessWidget { final l10n = context.l10n; return Scaffold( key: const Key('home_screen'), - appBar: AppBar(title: Text(l10n.appTitle)), + appBar: AppBar( + title: Text(l10n.appTitle), + actions: const [CatalogSearchEntryButton()], + ), body: Center( child: Padding( padding: const EdgeInsets.all(24), diff --git a/app/lib/l10n/app_de.arb b/app/lib/l10n/app_de.arb index 907b00b..1468e3f 100644 --- a/app/lib/l10n/app_de.arb +++ b/app/lib/l10n/app_de.arb @@ -79,5 +79,41 @@ "authResetCancel": "Abbrechen", "authPasswordChanged": "Passwort geändert. Andere Geräte werden abgemeldet; das kann bis zu einer Stunde dauern.", "gameCoverPlaceholderLabel": "Kein Cover für {title}", - "gameCoverLabel": "Cover von {title}" + "gameCoverLabel": "Cover von {title}", + "catalogSearchTitle": "Spiele suchen", + "catalogSearchFieldHint": "Titel oder Verlag", + "catalogSearchClear": "Suchbegriff löschen", + "catalogSearchFilterButton": "{count, plural, =0{Filter} other{Filter ({count})}}", + "catalogSearchFilterTitle": "Filter", + "catalogSearchFilterDone": "Fertig", + "catalogSearchFilterReset": "Filter zurücksetzen", + "catalogSearchFilterPlayers": "Spielerzahl", + "catalogSearchFilterAny": "Beliebig", + "catalogSearchFilterDuration": "Spieldauer", + "catalogSearchFilterDurationValue": "Bis {minutes} Min.", + "catalogSearchFilterAge": "Alter", + "catalogSearchFilterAgeValue": "Ab {age} Jahren geeignet", + "catalogSearchFilterGenres": "Genre", + "catalogSearchFilterMechanics": "Mechanik", + "catalogSearchTermsError": "Die Auswahl konnte nicht geladen werden.", + "catalogSearchRetry": "Erneut versuchen", + "catalogSearchResultCount": "{count, plural, =0{Keine Spiele} =1{1 Spiel} other{{count} Spiele}}", + "catalogSearchAnnouncement": "{count, plural, =0{Keine Spiele gefunden} =1{1 Spiel gefunden} other{{count} Spiele gefunden}}", + "catalogSearchAlsoKnownAs": "auch: {title}", + "catalogSearchPlayersCount": "{count, plural, =1{1 Spieler} other{{count} Spieler}}", + "catalogSearchPlayersRange": "{min}–{max} Spieler", + "catalogSearchMinutesSingle": "{minutes} Min.", + "catalogSearchMinutesRange": "{min}–{max} Min.", + "catalogSearchMinAge": "ab {age}", + "catalogSearchPublishersMore": "{names} +{more}", + "catalogSearchLoadMore": "Weitere laden", + "catalogSearchLoading": "Treffer werden geladen", + "catalogSearchLoadMoreError": "Weitere Treffer konnten nicht geladen werden.", + "catalogSearchEmptyForQuery": "Keine Spiele zu „{query}“ gefunden.", + "catalogSearchEmptyForQueryHint": "Prüfe die Schreibweise oder entferne Filter.", + "catalogSearchEmptyForFilters": "Keine Spiele passen zu deinen Filtern.", + "catalogSearchEmptyCatalog": "Der Katalog ist noch leer.", + "catalogSearchReportGame": "Fehlendes Spiel melden", + "catalogSearchErrorOffline": "Keine Verbindung. Für die Suche brauchst du Internet.", + "catalogSearchErrorGeneric": "Die Suche ist gerade nicht möglich." } diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index 86e8967..abc4c88 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -133,5 +133,77 @@ "@gameCoverLabel": { "description": "Semantics label of a game cover image", "placeholders": {"title": {"type": "String"}} - } + }, + "catalogSearchTitle": "Search games", + "@catalogSearchTitle": {"description": "Title of the catalog search screen and label of the search field/entry button"}, + "catalogSearchFieldHint": "Title or publisher", + "@catalogSearchFieldHint": {"description": "Hint text of the search field"}, + "catalogSearchClear": "Clear search", + "@catalogSearchClear": {"description": "Tooltip/label of the clear button of the search field"}, + "catalogSearchFilterButton": "{count, plural, =0{Filters} other{Filters ({count})}}", + "@catalogSearchFilterButton": {"description": "Button that opens the filters, with the number of active filters", "placeholders": {"count": {"type": "int"}}}, + "catalogSearchFilterTitle": "Filters", + "@catalogSearchFilterTitle": {"description": "Title of the filter area"}, + "catalogSearchFilterDone": "Done", + "@catalogSearchFilterDone": {"description": "Closes the filter sheet"}, + "catalogSearchFilterReset": "Reset filters", + "@catalogSearchFilterReset": {"description": "Removes all filters"}, + "catalogSearchFilterPlayers": "Number of players", + "@catalogSearchFilterPlayers": {"description": "Filter label"}, + "catalogSearchFilterAny": "Any", + "@catalogSearchFilterAny": {"description": "Filter value without restriction"}, + "catalogSearchFilterDuration": "Play time", + "@catalogSearchFilterDuration": {"description": "Filter label"}, + "catalogSearchFilterDurationValue": "Up to {minutes} min", + "@catalogSearchFilterDurationValue": {"description": "Filter value: games lasting at most this many minutes", "placeholders": {"minutes": {"type": "int"}}}, + "catalogSearchFilterAge": "Age", + "@catalogSearchFilterAge": {"description": "Filter label"}, + "catalogSearchFilterAgeValue": "Suitable from age {age}", + "@catalogSearchFilterAgeValue": {"description": "Filter value: games suitable for players of this age", "placeholders": {"age": {"type": "int"}}}, + "catalogSearchFilterGenres": "Genre", + "@catalogSearchFilterGenres": {"description": "Filter label"}, + "catalogSearchFilterMechanics": "Mechanic", + "@catalogSearchFilterMechanics": {"description": "Filter label"}, + "catalogSearchTermsError": "The options could not be loaded.", + "@catalogSearchTermsError": {"description": "Error in the genre/mechanic filter"}, + "catalogSearchRetry": "Try again", + "@catalogSearchRetry": {"description": "Retry button"}, + "catalogSearchResultCount": "{count, plural, =0{No games} =1{1 game} other{{count} games}}", + "@catalogSearchResultCount": {"description": "Number of hits above the list", "placeholders": {"count": {"type": "int"}}}, + "catalogSearchAnnouncement": "{count, plural, =0{No games found} =1{1 game found} other{{count} games found}}", + "@catalogSearchAnnouncement": {"description": "Screen reader announcement after a finished search", "placeholders": {"count": {"type": "int"}}}, + "catalogSearchAlsoKnownAs": "also: {title}", + "@catalogSearchAlsoKnownAs": {"description": "Alternative title that matched", "placeholders": {"title": {"type": "String"}}}, + "catalogSearchPlayersCount": "{count, plural, =1{1 player} other{{count} players}}", + "@catalogSearchPlayersCount": {"description": "Number of players of a game", "placeholders": {"count": {"type": "int"}}}, + "catalogSearchPlayersRange": "{min}–{max} players", + "@catalogSearchPlayersRange": {"description": "Player range of a game", "placeholders": {"min": {"type": "int"}, "max": {"type": "int"}}}, + "catalogSearchMinutesSingle": "{minutes} min", + "@catalogSearchMinutesSingle": {"description": "Play time of a game", "placeholders": {"minutes": {"type": "int"}}}, + "catalogSearchMinutesRange": "{min}–{max} min", + "@catalogSearchMinutesRange": {"description": "Play time range of a game", "placeholders": {"min": {"type": "int"}, "max": {"type": "int"}}}, + "catalogSearchMinAge": "ages {age}+", + "@catalogSearchMinAge": {"description": "Minimum age of a game", "placeholders": {"age": {"type": "int"}}}, + "catalogSearchPublishersMore": "{names} +{more}", + "@catalogSearchPublishersMore": {"description": "Publishers of a game, more than shown", "placeholders": {"names": {"type": "String"}, "more": {"type": "int"}}}, + "catalogSearchLoadMore": "Load more", + "@catalogSearchLoadMore": {"description": "Button at the end of the list"}, + "catalogSearchLoading": "Loading games", + "@catalogSearchLoading": {"description": "Spoken label of the loading indicator"}, + "catalogSearchLoadMoreError": "More games could not be loaded.", + "@catalogSearchLoadMoreError": {"description": "Error when loading the next page"}, + "catalogSearchEmptyForQuery": "No games found for “{query}”.", + "@catalogSearchEmptyForQuery": {"description": "Empty state with search text", "placeholders": {"query": {"type": "String"}}}, + "catalogSearchEmptyForQueryHint": "Check the spelling or remove filters.", + "@catalogSearchEmptyForQueryHint": {"description": "Hint in the empty state with search text"}, + "catalogSearchEmptyForFilters": "No games match your filters.", + "@catalogSearchEmptyForFilters": {"description": "Empty state with filters only"}, + "catalogSearchEmptyCatalog": "The catalog is still empty.", + "@catalogSearchEmptyCatalog": {"description": "Empty state without search text and filters"}, + "catalogSearchReportGame": "Report a missing game", + "@catalogSearchReportGame": {"description": "Action in the empty state"}, + "catalogSearchErrorOffline": "No connection. You need internet for the search.", + "@catalogSearchErrorOffline": {"description": "Error state: offline"}, + "catalogSearchErrorGeneric": "Search is not available right now.", + "@catalogSearchErrorGeneric": {"description": "Error state: other errors"} } diff --git a/app/lib/l10n/app_localizations.dart b/app/lib/l10n/app_localizations.dart index 2f5596f..717eb9d 100644 --- a/app/lib/l10n/app_localizations.dart +++ b/app/lib/l10n/app_localizations.dart @@ -577,6 +577,222 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Cover of {title}'** String gameCoverLabel(String title); + + /// Title of the catalog search screen and label of the search field/entry button + /// + /// In en, this message translates to: + /// **'Search games'** + String get catalogSearchTitle; + + /// Hint text of the search field + /// + /// In en, this message translates to: + /// **'Title or publisher'** + String get catalogSearchFieldHint; + + /// Tooltip/label of the clear button of the search field + /// + /// In en, this message translates to: + /// **'Clear search'** + String get catalogSearchClear; + + /// Button that opens the filters, with the number of active filters + /// + /// In en, this message translates to: + /// **'{count, plural, =0{Filters} other{Filters ({count})}}'** + String catalogSearchFilterButton(int count); + + /// Title of the filter area + /// + /// In en, this message translates to: + /// **'Filters'** + String get catalogSearchFilterTitle; + + /// Closes the filter sheet + /// + /// In en, this message translates to: + /// **'Done'** + String get catalogSearchFilterDone; + + /// Removes all filters + /// + /// In en, this message translates to: + /// **'Reset filters'** + String get catalogSearchFilterReset; + + /// Filter label + /// + /// In en, this message translates to: + /// **'Number of players'** + String get catalogSearchFilterPlayers; + + /// Filter value without restriction + /// + /// In en, this message translates to: + /// **'Any'** + String get catalogSearchFilterAny; + + /// Filter label + /// + /// In en, this message translates to: + /// **'Play time'** + String get catalogSearchFilterDuration; + + /// Filter value: games lasting at most this many minutes + /// + /// In en, this message translates to: + /// **'Up to {minutes} min'** + String catalogSearchFilterDurationValue(int minutes); + + /// Filter label + /// + /// In en, this message translates to: + /// **'Age'** + String get catalogSearchFilterAge; + + /// Filter value: games suitable for players of this age + /// + /// In en, this message translates to: + /// **'Suitable from age {age}'** + String catalogSearchFilterAgeValue(int age); + + /// Filter label + /// + /// In en, this message translates to: + /// **'Genre'** + String get catalogSearchFilterGenres; + + /// Filter label + /// + /// In en, this message translates to: + /// **'Mechanic'** + String get catalogSearchFilterMechanics; + + /// Error in the genre/mechanic filter + /// + /// In en, this message translates to: + /// **'The options could not be loaded.'** + String get catalogSearchTermsError; + + /// Retry button + /// + /// In en, this message translates to: + /// **'Try again'** + String get catalogSearchRetry; + + /// Number of hits above the list + /// + /// In en, this message translates to: + /// **'{count, plural, =0{No games} =1{1 game} other{{count} games}}'** + String catalogSearchResultCount(int count); + + /// Screen reader announcement after a finished search + /// + /// In en, this message translates to: + /// **'{count, plural, =0{No games found} =1{1 game found} other{{count} games found}}'** + String catalogSearchAnnouncement(int count); + + /// Alternative title that matched + /// + /// In en, this message translates to: + /// **'also: {title}'** + String catalogSearchAlsoKnownAs(String title); + + /// Number of players of a game + /// + /// In en, this message translates to: + /// **'{count, plural, =1{1 player} other{{count} players}}'** + String catalogSearchPlayersCount(int count); + + /// Player range of a game + /// + /// In en, this message translates to: + /// **'{min}–{max} players'** + String catalogSearchPlayersRange(int min, int max); + + /// Play time of a game + /// + /// In en, this message translates to: + /// **'{minutes} min'** + String catalogSearchMinutesSingle(int minutes); + + /// Play time range of a game + /// + /// In en, this message translates to: + /// **'{min}–{max} min'** + String catalogSearchMinutesRange(int min, int max); + + /// Minimum age of a game + /// + /// In en, this message translates to: + /// **'ages {age}+'** + String catalogSearchMinAge(int age); + + /// Publishers of a game, more than shown + /// + /// In en, this message translates to: + /// **'{names} +{more}'** + String catalogSearchPublishersMore(String names, int more); + + /// Button at the end of the list + /// + /// In en, this message translates to: + /// **'Load more'** + String get catalogSearchLoadMore; + + /// Spoken label of the loading indicator + /// + /// In en, this message translates to: + /// **'Loading games'** + String get catalogSearchLoading; + + /// Error when loading the next page + /// + /// In en, this message translates to: + /// **'More games could not be loaded.'** + String get catalogSearchLoadMoreError; + + /// Empty state with search text + /// + /// In en, this message translates to: + /// **'No games found for “{query}”.'** + String catalogSearchEmptyForQuery(String query); + + /// Hint in the empty state with search text + /// + /// In en, this message translates to: + /// **'Check the spelling or remove filters.'** + String get catalogSearchEmptyForQueryHint; + + /// Empty state with filters only + /// + /// In en, this message translates to: + /// **'No games match your filters.'** + String get catalogSearchEmptyForFilters; + + /// Empty state without search text and filters + /// + /// In en, this message translates to: + /// **'The catalog is still empty.'** + String get catalogSearchEmptyCatalog; + + /// Action in the empty state + /// + /// In en, this message translates to: + /// **'Report a missing game'** + String get catalogSearchReportGame; + + /// Error state: offline + /// + /// In en, this message translates to: + /// **'No connection. You need internet for the search.'** + String get catalogSearchErrorOffline; + + /// Error state: other errors + /// + /// In en, this message translates to: + /// **'Search is not available right now.'** + String get catalogSearchErrorGeneric; } class _AppLocalizationsDelegate diff --git a/app/lib/l10n/app_localizations_de.dart b/app/lib/l10n/app_localizations_de.dart index 899aaf2..e5d050f 100644 --- a/app/lib/l10n/app_localizations_de.dart +++ b/app/lib/l10n/app_localizations_de.dart @@ -292,4 +292,169 @@ class AppLocalizationsDe extends AppLocalizations { String gameCoverLabel(String title) { return 'Cover von $title'; } + + @override + String get catalogSearchTitle => 'Spiele suchen'; + + @override + String get catalogSearchFieldHint => 'Titel oder Verlag'; + + @override + String get catalogSearchClear => 'Suchbegriff löschen'; + + @override + String catalogSearchFilterButton(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Filter ($count)', + zero: 'Filter', + ); + return '$_temp0'; + } + + @override + String get catalogSearchFilterTitle => 'Filter'; + + @override + String get catalogSearchFilterDone => 'Fertig'; + + @override + String get catalogSearchFilterReset => 'Filter zurücksetzen'; + + @override + String get catalogSearchFilterPlayers => 'Spielerzahl'; + + @override + String get catalogSearchFilterAny => 'Beliebig'; + + @override + String get catalogSearchFilterDuration => 'Spieldauer'; + + @override + String catalogSearchFilterDurationValue(int minutes) { + return 'Bis $minutes Min.'; + } + + @override + String get catalogSearchFilterAge => 'Alter'; + + @override + String catalogSearchFilterAgeValue(int age) { + return 'Ab $age Jahren geeignet'; + } + + @override + String get catalogSearchFilterGenres => 'Genre'; + + @override + String get catalogSearchFilterMechanics => 'Mechanik'; + + @override + String get catalogSearchTermsError => + 'Die Auswahl konnte nicht geladen werden.'; + + @override + String get catalogSearchRetry => 'Erneut versuchen'; + + @override + String catalogSearchResultCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count Spiele', + one: '1 Spiel', + zero: 'Keine Spiele', + ); + return '$_temp0'; + } + + @override + String catalogSearchAnnouncement(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count Spiele gefunden', + one: '1 Spiel gefunden', + zero: 'Keine Spiele gefunden', + ); + return '$_temp0'; + } + + @override + String catalogSearchAlsoKnownAs(String title) { + return 'auch: $title'; + } + + @override + String catalogSearchPlayersCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count Spieler', + one: '1 Spieler', + ); + return '$_temp0'; + } + + @override + String catalogSearchPlayersRange(int min, int max) { + return '$min–$max Spieler'; + } + + @override + String catalogSearchMinutesSingle(int minutes) { + return '$minutes Min.'; + } + + @override + String catalogSearchMinutesRange(int min, int max) { + return '$min–$max Min.'; + } + + @override + String catalogSearchMinAge(int age) { + return 'ab $age'; + } + + @override + String catalogSearchPublishersMore(String names, int more) { + return '$names +$more'; + } + + @override + String get catalogSearchLoadMore => 'Weitere laden'; + + @override + String get catalogSearchLoading => 'Treffer werden geladen'; + + @override + String get catalogSearchLoadMoreError => + 'Weitere Treffer konnten nicht geladen werden.'; + + @override + String catalogSearchEmptyForQuery(String query) { + return 'Keine Spiele zu „$query“ gefunden.'; + } + + @override + String get catalogSearchEmptyForQueryHint => + 'Prüfe die Schreibweise oder entferne Filter.'; + + @override + String get catalogSearchEmptyForFilters => + 'Keine Spiele passen zu deinen Filtern.'; + + @override + String get catalogSearchEmptyCatalog => 'Der Katalog ist noch leer.'; + + @override + String get catalogSearchReportGame => 'Fehlendes Spiel melden'; + + @override + String get catalogSearchErrorOffline => + 'Keine Verbindung. Für die Suche brauchst du Internet.'; + + @override + String get catalogSearchErrorGeneric => 'Die Suche ist gerade nicht möglich.'; } diff --git a/app/lib/l10n/app_localizations_en.dart b/app/lib/l10n/app_localizations_en.dart index eb22e28..a6497cb 100644 --- a/app/lib/l10n/app_localizations_en.dart +++ b/app/lib/l10n/app_localizations_en.dart @@ -289,4 +289,166 @@ class AppLocalizationsEn extends AppLocalizations { String gameCoverLabel(String title) { return 'Cover of $title'; } + + @override + String get catalogSearchTitle => 'Search games'; + + @override + String get catalogSearchFieldHint => 'Title or publisher'; + + @override + String get catalogSearchClear => 'Clear search'; + + @override + String catalogSearchFilterButton(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: 'Filters ($count)', + zero: 'Filters', + ); + return '$_temp0'; + } + + @override + String get catalogSearchFilterTitle => 'Filters'; + + @override + String get catalogSearchFilterDone => 'Done'; + + @override + String get catalogSearchFilterReset => 'Reset filters'; + + @override + String get catalogSearchFilterPlayers => 'Number of players'; + + @override + String get catalogSearchFilterAny => 'Any'; + + @override + String get catalogSearchFilterDuration => 'Play time'; + + @override + String catalogSearchFilterDurationValue(int minutes) { + return 'Up to $minutes min'; + } + + @override + String get catalogSearchFilterAge => 'Age'; + + @override + String catalogSearchFilterAgeValue(int age) { + return 'Suitable from age $age'; + } + + @override + String get catalogSearchFilterGenres => 'Genre'; + + @override + String get catalogSearchFilterMechanics => 'Mechanic'; + + @override + String get catalogSearchTermsError => 'The options could not be loaded.'; + + @override + String get catalogSearchRetry => 'Try again'; + + @override + String catalogSearchResultCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count games', + one: '1 game', + zero: 'No games', + ); + return '$_temp0'; + } + + @override + String catalogSearchAnnouncement(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count games found', + one: '1 game found', + zero: 'No games found', + ); + return '$_temp0'; + } + + @override + String catalogSearchAlsoKnownAs(String title) { + return 'also: $title'; + } + + @override + String catalogSearchPlayersCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count players', + one: '1 player', + ); + return '$_temp0'; + } + + @override + String catalogSearchPlayersRange(int min, int max) { + return '$min–$max players'; + } + + @override + String catalogSearchMinutesSingle(int minutes) { + return '$minutes min'; + } + + @override + String catalogSearchMinutesRange(int min, int max) { + return '$min–$max min'; + } + + @override + String catalogSearchMinAge(int age) { + return 'ages $age+'; + } + + @override + String catalogSearchPublishersMore(String names, int more) { + return '$names +$more'; + } + + @override + String get catalogSearchLoadMore => 'Load more'; + + @override + String get catalogSearchLoading => 'Loading games'; + + @override + String get catalogSearchLoadMoreError => 'More games could not be loaded.'; + + @override + String catalogSearchEmptyForQuery(String query) { + return 'No games found for “$query”.'; + } + + @override + String get catalogSearchEmptyForQueryHint => + 'Check the spelling or remove filters.'; + + @override + String get catalogSearchEmptyForFilters => 'No games match your filters.'; + + @override + String get catalogSearchEmptyCatalog => 'The catalog is still empty.'; + + @override + String get catalogSearchReportGame => 'Report a missing game'; + + @override + String get catalogSearchErrorOffline => + 'No connection. You need internet for the search.'; + + @override + String get catalogSearchErrorGeneric => 'Search is not available right now.'; } diff --git a/app/test/features/catalog/catalog_test_support.dart b/app/test/features/catalog/catalog_test_support.dart new file mode 100644 index 0000000..2922532 --- /dev/null +++ b/app/test/features/catalog/catalog_test_support.dart @@ -0,0 +1,151 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/misc.dart' show Override; +import 'package:shuffle/core/result/result.dart'; +import 'package:shuffle/features/catalog/domain/catalog_failure.dart'; +import 'package:shuffle/features/catalog/domain/catalog_game_summary.dart'; +import 'package:shuffle/features/catalog/domain/catalog_repository.dart'; +import 'package:shuffle/features/catalog/domain/catalog_search_page.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/providers/catalog_providers.dart'; + +import '../../helpers/fakes.dart'; + +/// UUIDs for filter tests. +const genreStrategy = '11111111-1111-4111-8111-111111111111'; +const genreFamily = '22222222-2222-4222-8222-222222222222'; +const mechanicDice = '33333333-3333-4333-8333-333333333333'; + +const testGenres = [ + CatalogTerm(id: genreStrategy, nameDe: 'Strategie', nameEn: 'Strategy'), + CatalogTerm(id: genreFamily, nameDe: 'Familie', nameEn: 'Family'), +]; + +const testMechanics = [ + CatalogTerm(id: mechanicDice, nameDe: 'Würfeln', nameEn: 'Dice rolling'), +]; + +CatalogGameSummary game( + int n, { + String? title, + int? year = 1995, + int? minPlayers = 3, + int? maxPlayers = 4, + int? minMinutes = 60, + int? maxMinutes = 120, + int? minAge = 10, + List publishers = const ['Kosmos'], + String? matchedAltTitle, +}) => CatalogGameSummary( + id: 'game-$n', + title: title ?? 'Spiel $n', + year: year, + minPlayers: minPlayers, + maxPlayers: maxPlayers, + minMinutes: minMinutes, + maxMinutes: maxMinutes, + minAge: minAge, + publisherNames: publishers, + matchedAltTitle: matchedAltTitle, +); + +/// One recorded call of [FakeCatalogRepository.search]. +class SearchCall { + SearchCall(this.query, this.offset, this.limit, this.completer); + + final CatalogSearchQuery query; + final int offset; + final int limit; + final Completer> completer; +} + +/// Fake [CatalogRepository]. By default it answers at once from [all] (a +/// list of games it filters by nothing but pages by offset/limit). With +/// [manual] every search waits until the test completes it. +class FakeCatalogRepository implements CatalogRepository { + FakeCatalogRepository({ + this.all = const [], + this.manual = false, + this.genres = testGenres, + this.mechanics = testMechanics, + }); + + List all; + bool manual; + List genres; + List mechanics; + + /// Failure to return instead of results (null = none). + CatalogFailure? failWith; + CatalogFailure? termsFailWith; + + final List calls = []; + + @override + Future> search( + CatalogSearchQuery query, { + required int offset, + int limit = CatalogSearchLimits.pageSize, + }) { + final completer = Completer>(); + final call = SearchCall(query, offset, limit, completer); + calls.add(call); + if (!manual) { + completer.complete(_answer(offset, limit)); + } + return completer.future; + } + + Result _answer(int offset, int limit) { + final failure = failWith; + if (failure != null) { + return Result.failure(failure); + } + final end = (offset + limit).clamp(0, all.length); + final start = offset.clamp(0, all.length); + return Result.success( + CatalogSearchPage(items: all.sublist(start, end), totalCount: all.length), + ); + } + + /// Completes the pending call [index] (manual mode). + void complete( + int index, { + List? items, + int? totalCount, + CatalogFailure? failure, + }) { + final call = calls[index]; + if (failure != null) { + call.completer.complete(Result.failure(failure)); + return; + } + final list = items ?? all; + call.completer.complete( + Result.success( + CatalogSearchPage(items: list, totalCount: totalCount ?? list.length), + ), + ); + } + + @override + Future, CatalogFailure>> fetchGenres() async { + final failure = termsFailWith; + return failure != null ? Result.failure(failure) : Result.success(genres); + } + + @override + Future, CatalogFailure>> fetchMechanics() async { + final failure = termsFailWith; + return failure != null + ? Result.failure(failure) + : Result.success(mechanics); + } +} + +/// Overrides with [repository] as catalog repository. +List catalogOverrides(FakeCatalogRepository repository) => [ + ...testOverrides(), + catalogRepositoryProvider.overrideWithValue(repository), +]; diff --git a/app/test/features/catalog/data/supabase_catalog_repository_test.dart b/app/test/features/catalog/data/supabase_catalog_repository_test.dart new file mode 100644 index 0000000..5f396f9 --- /dev/null +++ b/app/test/features/catalog/data/supabase_catalog_repository_test.dart @@ -0,0 +1,316 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:logging/logging.dart'; +import 'package:shuffle/core/logging/app_logger.dart'; +import 'package:shuffle/features/catalog/domain/catalog_failure.dart'; +import 'package:shuffle/features/catalog/domain/catalog_search_query.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:shuffle/features/catalog/data/supabase_catalog_repository.dart'; + +import '../catalog_test_support.dart'; + +/// Tests the repository with a real [SupabaseClient] whose HTTP layer is a +/// fake: the requests that would reach the backend are recorded. The real RPC +/// (backend 3a) is not part of these tests. +void main() { + late List requests; + late SupabaseClient client; + late http.Response Function(http.Request) respond; + late SupabaseCatalogRepository repository; + + setUp(() { + requests = []; + respond = (request) => http.Response('[]', 200); + client = SupabaseClient( + 'http://localhost:54321', + 'anon-key', + httpClient: MockClient((request) async { + requests.add(request); + final response = respond(request); + // The postgrest parser reads `response.request`. + return http.Response.bytes( + response.bodyBytes, + response.statusCode, + headers: response.headers, + request: request, + ); + }), + ); + repository = SupabaseCatalogRepository(client); + }); + + tearDown(() async { + await client.dispose(); + }); + + Map body(http.Request request) => + jsonDecode(request.body) as Map; + + Map row(int n, {int total = 2}) => { + 'id': 'game-$n', + 'title': 'Spiel $n', + 'year': 1995, + 'min_players': 3, + 'max_players': 4, + 'min_minutes': 60, + 'max_minutes': 120, + 'min_age': 10, + 'image_path': null, + 'publisher_names': ['Kosmos'], + 'matched_alt_title': null, + 'match_rank': 1, + 'total_count': total, + }; + + group('search parameters', () { + test('KAT-01 Kriterium 1: sends the text and paging via POST', () async { + await repository.search( + const CatalogSearchQuery(text: 'catan'), + offset: 30, + ); + + expect(requests, hasLength(1)); + final request = requests.single; + expect(request.method, 'POST'); + expect(request.url.path, '/rest/v1/rpc/search_catalog_games'); + // The search text is never part of the URL (privacy). + expect(request.url.toString(), isNot(contains('catan'))); + expect(body(request), { + 'p_query': 'catan', + 'p_limit': 30, + 'p_offset': 30, + }); + }); + + test('KAT-01 Kriterium 1: text below 2 characters is not sent', () async { + await repository.search(const CatalogSearchQuery(text: 'c'), offset: 0); + + expect(body(requests.single), {'p_limit': 30, 'p_offset': 0}); + }); + + test( + 'KAT-01 Kriterium 8: no filters and no text sends paging only', + () async { + await repository.search(const CatalogSearchQuery(), offset: 0); + + expect(body(requests.single), {'p_limit': 30, 'p_offset': 0}); + }, + ); + + test('KAT-01 Kriterium 4: sends all set filters, no empty sets', () async { + await repository.search( + const CatalogSearchQuery( + filters: CatalogFilters( + players: 4, + maxMinutes: 60, + minAgeSuitable: 10, + genreIds: {genreStrategy}, + ), + ), + offset: 0, + limit: 10, + ); + + expect(body(requests.single), { + 'p_players': 4, + 'p_max_minutes': 60, + 'p_age': 10, + 'p_genre_ids': [genreStrategy], + 'p_limit': 10, + 'p_offset': 0, + }); + expect(body(requests.single), isNot(contains('p_mechanic_ids'))); + }); + + test('KAT-01 Kriterium 4: mechanics are sent as p_mechanic_ids', () async { + await repository.search( + const CatalogSearchQuery( + filters: CatalogFilters(mechanicIds: {mechanicDice}), + ), + offset: 0, + ); + + expect(body(requests.single)['p_mechanic_ids'], [mechanicDice]); + }); + }); + + group('search result', () { + test('KAT-01 Kriterium 1: parses rows and the total count', () async { + respond = (request) => http.Response( + jsonEncode([row(1, total: 42), row(2, total: 42)]), + 200, + headers: {'content-type': 'application/json'}, + ); + + final result = await repository.search( + const CatalogSearchQuery(text: 'spiel'), + offset: 0, + ); + + final page = result.valueOrNull!; + expect(page.totalCount, 42); + expect(page.items.map((g) => g.id), ['game-1', 'game-2']); + expect(page.items.first.minPlayers, 3); + expect(page.items.first.publisherNames, ['Kosmos']); + }); + + test('no rows give an empty page with total 0', () async { + final result = await repository.search( + const CatalogSearchQuery(text: 'nichts'), + offset: 0, + ); + + expect(result.valueOrNull!.items, isEmpty); + expect(result.valueOrNull!.totalCount, 0); + }); + }); + + group('vocabulary', () { + test('KAT-01 Kriterium 4: reads genres from the table', () async { + respond = (request) => http.Response( + jsonEncode([ + {'id': genreStrategy, 'name_de': 'Strategie', 'name_en': 'Strategy'}, + ]), + 200, + headers: {'content-type': 'application/json'}, + ); + + final result = await repository.fetchGenres(); + + expect(requests.single.method, 'GET'); + expect(requests.single.url.path, '/rest/v1/genres'); + expect(result.valueOrNull!.single.nameEn, 'Strategy'); + }); + + test('reads mechanics from the table', () async { + await repository.fetchMechanics(); + + expect(requests.single.url.path, '/rest/v1/mechanics'); + }); + + test('a failing vocabulary call gives a failure', () async { + // (A transport error would be retried by postgrest for several seconds.) + respond = (request) => http.Response( + jsonEncode({'code': 'XX000', 'message': 'boom'}), + 500, + headers: {'content-type': 'application/json'}, + ); + + final result = await repository.fetchGenres(); + + expect(result.errorOrNull, isA()); + }); + }); + + group('failures', () { + test('Zustände Kriterium 3: connection error maps to network', () async { + respond = (request) => throw http.ClientException('offline'); + + final result = await repository.search( + const CatalogSearchQuery(), + offset: 0, + ); + + expect(result.errorOrNull, isA()); + }); + + test('Zustände Kriterium 3: timeout maps to network', () async { + final slow = SupabaseCatalogRepository( + SupabaseClient( + 'http://localhost:54321', + 'anon-key', + httpClient: MockClient((request) { + return Completer().future; + }), + ), + timeout: const Duration(milliseconds: 20), + ); + + final result = await slow.search(const CatalogSearchQuery(), offset: 0); + + expect(result.errorOrNull, isA()); + }); + + test('P0001 from the RPC maps to invalidInput', () async { + respond = (request) => http.Response( + jsonEncode({ + 'code': 'P0001', + 'message': 'search_query_too_long', + 'details': null, + 'hint': null, + }), + 400, + headers: {'content-type': 'application/json'}, + ); + + final result = await repository.search( + const CatalogSearchQuery(text: 'ab'), + offset: 0, + ); + + expect(result.errorOrNull, isA()); + }); + + test('other server errors map to unexpected', () async { + respond = (request) => http.Response( + jsonEncode({'code': '42501', 'message': 'denied'}), + 403, + headers: {'content-type': 'application/json'}, + ); + + final result = await repository.search( + const CatalogSearchQuery(), + offset: 0, + ); + + expect(result.errorOrNull, isA()); + }); + + test('a malformed answer maps to unexpected', () async { + respond = (request) => http.Response( + '{"not":"a list"}', + 200, + headers: {'content-type': 'application/json'}, + ); + + final result = await repository.search( + const CatalogSearchQuery(), + offset: 0, + ); + + expect(result.errorOrNull, isA()); + }); + + test('QUE-04: logs the failure kind, never the search text', () async { + const secret = 'geheimer-suchbegriff'; + final records = []; + Logger.root.level = Level.ALL; + final subscription = Logger.root.onRecord.listen(records.add); + addTearDown(subscription.cancel); + // Make the exception text echo the request, as a server might. + respond = (request) => http.Response( + jsonEncode({'code': 'P0001', 'message': 'bad $secret'}), + 400, + headers: {'content-type': 'application/json'}, + ); + AppLogger('probe').debug('probe'); + + await repository.search( + const CatalogSearchQuery(text: secret), + offset: 0, + ); + + final catalog = records.where((r) => r.loggerName == 'CatalogRepository'); + expect(catalog, isNotEmpty); + for (final record in catalog) { + expect(record.message, isNot(contains(secret))); + expect(record.error, isNull); + expect(record.stackTrace, isNull); + } + }); + }); +} diff --git a/app/test/features/catalog/domain/catalog_domain_test.dart b/app/test/features/catalog/domain/catalog_domain_test.dart new file mode 100644 index 0000000..6ad95d9 --- /dev/null +++ b/app/test/features/catalog/domain/catalog_domain_test.dart @@ -0,0 +1,163 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:shuffle/features/catalog/data/catalog_failure_mapper.dart'; +import 'package:shuffle/features/catalog/data/catalog_game_summary_dto.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/search/catalog_search_state.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +import '../catalog_test_support.dart'; + +void main() { + group('CatalogTerm', () { + const term = CatalogTerm(id: 'x', nameDe: 'Würfeln', nameEn: 'Dice'); + + test('QUE-05 Kriterium 4: German for de, English otherwise', () { + expect(term.localizedName('de'), 'Würfeln'); + expect(term.localizedName('en'), 'Dice'); + expect(term.localizedName('fr'), 'Dice'); + }); + }); + + group('CatalogSearchQuery', () { + test('KAT-01 Kriterium 1: text counts from 2 characters', () { + expect(const CatalogSearchQuery().hasText, isFalse); + expect(const CatalogSearchQuery(text: 'c').hasText, isFalse); + expect(const CatalogSearchQuery(text: 'ca').hasText, isTrue); + }); + + test('a text below the minimum is dropped from the effective query', () { + expect(const CatalogSearchQuery(text: 'c').effective.text, ''); + expect(const CatalogSearchQuery(text: 'ca').effective.text, 'ca'); + }); + + test('cleanText trims and cuts to 100 characters', () { + expect(CatalogSearchQuery.cleanText(' catan '), 'catan'); + expect(CatalogSearchQuery.cleanText('a' * 150), hasLength(100)); + }); + + test('KAT-01 Kriterium 4: activeFilterCount counts every filter value', () { + const filters = CatalogFilters( + players: 3, + maxMinutes: 60, + minAgeSuitable: 8, + genreIds: {genreStrategy, genreFamily}, + mechanicIds: {mechanicDice}, + ); + + expect(filters.activeCount, 6); + expect(const CatalogFilters().isEmpty, isTrue); + expect(const CatalogSearchQuery(filters: filters).hasFilters, isTrue); + }); + + test('KAT-01 Kriterium 8: no text and no filters is the whole catalog', () { + expect(const CatalogSearchQuery().isEmpty, isTrue); + expect(const CatalogSearchQuery(text: 'x').isEmpty, isTrue); + expect(const CatalogSearchQuery(text: 'xx').isEmpty, isFalse); + }); + + test('Eingabegrenzen: out-of-range values are rejected', () { + expect(() => CatalogFilters(players: 0), throwsA(isA())); + expect(() => CatalogFilters(players: 13), throwsA(isA())); + expect( + () => CatalogSearchQuery(text: 'a' * 101), + throwsA(isA()), + ); + }); + }); + + group('CatalogSearchState', () { + test('KAT-01 Kriterium 5: hasMore compares items and total', () { + final state = CatalogSearchState(items: [game(1)], totalCount: 2); + + expect(state.hasMore, isTrue); + expect(state.copyWith(totalCount: 1).hasMore, isFalse); + }); + + test('KAT-01 Kriterium 5: no more pages beyond offset 10000', () { + final state = CatalogSearchState( + items: List.generate(10000, game), + totalCount: 20000, + ); + + expect(state.hasMore, isFalse); + }); + }); + + group('CatalogGameSummaryDto', () { + test('KAT-01 Kriterium 2: parses matched_alt_title and numbers', () { + final dto = CatalogGameSummaryDto.fromJson({ + 'id': 'a', + 'title': 'Catan', + 'year': 1995, + 'min_players': 3.0, + 'max_players': 4, + 'min_minutes': null, + 'max_minutes': null, + 'min_age': 10, + 'image_path': null, + 'publisher_names': ['Kosmos', 'Franckh'], + 'matched_alt_title': 'Die Siedler von Catan', + 'match_rank': 0, + 'total_count': 12, + }); + + final game = dto.toDomain(); + expect(game.matchedAltTitle, 'Die Siedler von Catan'); + expect(game.minPlayers, 3); + expect(game.minMinutes, isNull); + expect(game.publisherNames, ['Kosmos', 'Franckh']); + }); + + test('all optional fields may be missing, publisher_names is empty', () { + final game = CatalogGameSummaryDto.fromJson({ + 'id': 'a', + 'title': 'Catan', + 'publisher_names': null, + }).toDomain(); + + expect(game.year, isNull); + expect(game.imagePath, isNull); + expect(game.publisherNames, isEmpty); + }); + }); + + group('mapCatalogError', () { + test('Zustände Kriterium 3: transport errors and timeouts are network', () { + expect( + mapCatalogError(TimeoutException('slow')), + isA(), + ); + expect( + mapCatalogError(const SocketException('down')), + isA(), + ); + expect( + mapCatalogError(http.ClientException('down')), + isA(), + ); + }); + + test('P0001 is invalidInput, other Postgrest errors are unexpected', () { + expect( + mapCatalogError( + const PostgrestException( + message: 'search_filter_invalid', + code: 'P0001', + ), + ), + isA(), + ); + expect( + mapCatalogError(const PostgrestException(message: 'x', code: '42501')), + isA(), + ); + expect(mapCatalogError(StateError('x')), isA()); + }); + }); +} diff --git a/app/test/features/catalog/presentation/catalog_query_params_test.dart b/app/test/features/catalog/presentation/catalog_query_params_test.dart new file mode 100644 index 0000000..3a315ce --- /dev/null +++ b/app/test/features/catalog/presentation/catalog_query_params_test.dart @@ -0,0 +1,77 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shuffle/features/catalog/domain/catalog_search_query.dart'; +import 'package:shuffle/features/catalog/presentation/catalog_query_params.dart'; + +import '../catalog_test_support.dart'; + +void main() { + test('KAT-01 Kriterium 4: URL round trip keeps text and all filters', () { + const query = CatalogSearchQuery( + text: 'catan kosmos', + filters: CatalogFilters( + players: 4, + maxMinutes: 90, + minAgeSuitable: 12, + genreIds: {genreStrategy, genreFamily}, + mechanicIds: {mechanicDice}, + ), + ); + + final params = catalogQueryParameters(query); + final uri = catalogLocation('/catalog', query); + final parsed = parseCatalogQuery(uri.queryParameters); + + expect(params['q'], 'catan kosmos'); + expect(parsed, query); + }); + + test('an empty query has no parameters and no query string', () { + expect(catalogQueryParameters(const CatalogSearchQuery()), isEmpty); + expect( + catalogLocation('/catalog', const CatalogSearchQuery()).toString(), + '/catalog', + ); + }); + + test('special characters in the text survive the URL', () { + const query = CatalogSearchQuery(text: 'Æon’s End & Co/ä?'); + + final uri = Uri.parse(catalogLocation('/catalog', query).toString()); + + expect(parseCatalogQuery(uri.queryParameters), query); + }); + + test('invalid or unknown values are ignored silently', () { + final parsed = parseCatalogQuery({ + 'players': '13', + 'maxMinutes': '75', + 'age': 'abc', + 'genre': 'not-a-uuid,$genreStrategy', + 'mechanic': ',,', + 'unknown': 'x', + }); + + expect(parsed.filters.players, isNull); + expect(parsed.filters.maxMinutes, isNull); + expect(parsed.filters.minAgeSuitable, isNull); + expect(parsed.filters.genreIds, {genreStrategy}); + expect(parsed.filters.mechanicIds, isEmpty); + }); + + test('Eingabegrenzen: text is trimmed and cut to 100 characters', () { + final parsed = parseCatalogQuery({'q': ' ${'a' * 150} '}); + + expect(parsed.text, hasLength(100)); + }); + + test('Eingabegrenzen: at most 20 genre IDs are taken', () { + final ids = [ + for (var i = 0; i < 30; i++) + '00000000-0000-4000-8000-${i.toString().padLeft(12, '0')}', + ]; + + final parsed = parseCatalogQuery({'genre': ids.join(',')}); + + expect(parsed.filters.genreIds, hasLength(20)); + }); +} diff --git a/app/test/features/catalog/presentation/search/catalog_search_controller_test.dart b/app/test/features/catalog/presentation/search/catalog_search_controller_test.dart new file mode 100644 index 0000000..5d2e7f3 --- /dev/null +++ b/app/test/features/catalog/presentation/search/catalog_search_controller_test.dart @@ -0,0 +1,401 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.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/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 '../../catalog_test_support.dart'; + +void main() { + late FakeCatalogRepository repository; + late ProviderContainer container; + + CatalogSearchController controller() => + container.read(catalogSearchControllerProvider.notifier); + CatalogSearchState state() => container.read(catalogSearchControllerProvider); + + /// Wires a fresh container to [repo]; the controller is kept alive. + void useRepository(FakeCatalogRepository repo) { + repository = repo; + container = ProviderContainer.test( + overrides: [catalogRepositoryProvider.overrideWithValue(repo)], + ); + container.listen(catalogSearchControllerProvider, (_, _) {}); + } + + /// Runs [body] with the fake clock of `testWidgets` (`tester.pump` moves + /// time, so the debounce `Timer` is testable) and a container wired to a + /// fake repository with 75 games. + void run(String name, Future Function(WidgetTester tester) body) { + testWidgets(name, (tester) async { + useRepository(FakeCatalogRepository(all: List.generate(75, game))); + await body(tester); + await tester.pump(); + }); + } + + run('KAT-01 Kriterium 8: init without text and filters shows the catalog', ( + tester, + ) async { + expect(state().status, CatalogSearchStatus.loading); + + controller().init(const CatalogSearchQuery()); + await tester.pump(); + + expect(state().status, CatalogSearchStatus.data); + expect(state().items, hasLength(30)); + expect(state().totalCount, 75); + expect(repository.calls.single.offset, 0); + expect(repository.calls.single.query.isEmpty, isTrue); + }); + + run('init uses the start values and runs only once', (tester) async { + const initial = CatalogSearchQuery(text: 'catan'); + controller().init(initial); + controller().init(const CatalogSearchQuery(text: 'other')); + await tester.pump(); + + expect(repository.calls, hasLength(1)); + expect(state().query.text, 'catan'); + expect(state().searchedQuery.text, 'catan'); + }); + + run('KAT-01 Kriterium 1: typing searches after the 300 ms debounce', ( + tester, + ) async { + controller().init(const CatalogSearchQuery()); + await tester.pump(); + repository.calls.clear(); + + controller().setText('ca'); + await tester.pump(const Duration(milliseconds: 299)); + expect(repository.calls, isEmpty); + + controller().setText('cat'); + await tester.pump(const Duration(milliseconds: 299)); + expect(repository.calls, isEmpty, reason: 'every keystroke restarts it'); + + await tester.pump(const Duration(milliseconds: 1)); + await tester.pump(); + expect(repository.calls, hasLength(1)); + expect(repository.calls.single.query.text, 'cat'); + expect(repository.calls.single.offset, 0); + }); + + run('KAT-01 Kriterium 1: Enter searches at once and cancels the debounce', ( + tester, + ) async { + controller().init(const CatalogSearchQuery()); + await tester.pump(); + repository.calls.clear(); + + controller() + ..setText('catan') + ..submitText('catan'); + await tester.pump(); + expect(repository.calls, hasLength(1)); + + await tester.pump(const Duration(seconds: 1)); + expect(repository.calls, hasLength(1), reason: 'no second search'); + }); + + run('KAT-01 Kriterium 8: text below 2 characters acts like no text', ( + tester, + ) async { + controller().init(const CatalogSearchQuery(text: 'catan')); + await tester.pump(); + repository.calls.clear(); + + controller().setText('c'); + await tester.pump(catalogSearchDebounce); + await tester.pump(); + + expect(repository.calls, hasLength(1)); + expect(repository.calls.single.query.effective.text, ''); + + // Typing a second single-character variant changes nothing effective. + controller().setText('d'); + await tester.pump(catalogSearchDebounce); + await tester.pump(); + expect(repository.calls, hasLength(1)); + }); + + run('KAT-01 Kriterium 1: only the latest request counts', (tester) async { + final repo = FakeCatalogRepository(manual: true); + useRepository(repo); + + + controller().init(const CatalogSearchQuery()); + await tester.pump(); + controller().submitText('alpha'); + await tester.pump(); + controller().submitText('beta'); + await tester.pump(); + expect(repo.calls, hasLength(3)); + + // The newest answer comes first, then the stale ones. + repo.complete(2, items: [game(3, title: 'Beta')]); + await tester.pump(); + repo.complete(1, items: [game(2, title: 'Alpha')]); + repo.complete(0, items: [game(1)]); + await tester.pump(); + + expect(state().items.map((g) => g.title), ['Beta']); + expect(state().searchedQuery.text, 'beta'); + }); + + run('KAT-01 Kriterium 4: changing filters searches at offset 0', ( + tester, + ) async { + controller().init(const CatalogSearchQuery()); + await tester.pump(); + controller().loadMore(); + await tester.pump(); + repository.calls.clear(); + + controller().setFilters(const CatalogFilters(players: 4)); + await tester.pump(); + + expect(repository.calls.single.offset, 0); + expect(repository.calls.single.query.filters.players, 4); + expect(state().query.activeFilterCount, 1); + }); + + run('a pending debounce is executed together with a filter change', ( + tester, + ) async { + controller().init(const CatalogSearchQuery()); + await tester.pump(); + repository.calls.clear(); + + controller().setText('catan'); + controller().setFilters(const CatalogFilters(maxMinutes: 60)); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + expect(repository.calls, hasLength(1)); + expect(repository.calls.single.query.text, 'catan'); + expect(repository.calls.single.query.filters.maxMinutes, 60); + }); + + run('KAT-01 Kriterium 8: clearFilters keeps the text', (tester) async { + controller().init( + const CatalogSearchQuery( + text: 'catan', + filters: CatalogFilters(players: 2), + ), + ); + await tester.pump(); + + controller().clearFilters(); + await tester.pump(); + + expect(state().query.text, 'catan'); + expect(state().query.hasFilters, isFalse); + }); + + run('KAT-01 Kriterium 5: loadMore appends the next page', (tester) async { + controller().init(const CatalogSearchQuery()); + await tester.pump(); + + controller().loadMore(); + await tester.pump(); + + expect(state().items, hasLength(60)); + expect(repository.calls.last.offset, 30); + expect(state().hasMore, isTrue); + + controller().loadMore(); + await tester.pump(); + expect(state().items, hasLength(75)); + expect(state().hasMore, isFalse); + + // Nothing more to load. + controller().loadMore(); + await tester.pump(); + expect(repository.calls, hasLength(3)); + }); + + run('KAT-01 Kriterium 5: loadMore does not run twice at the same time', ( + tester, + ) async { + final repo = FakeCatalogRepository( + all: List.generate(75, game), + manual: true, + ); + useRepository(repo); + + controller().init(const CatalogSearchQuery()); + await tester.pump(); + repo.complete(0, items: repo.all.sublist(0, 30), totalCount: 75); + await tester.pump(); + + controller() + ..loadMore() + ..loadMore(); + await tester.pump(); + + expect(repo.calls, hasLength(2)); + expect(state().loadingMore, isTrue); + }); + + run('KAT-01 Kriterium 5: duplicate IDs of a later page are dropped', ( + tester, + ) async { + final repo = FakeCatalogRepository(manual: true); + useRepository(repo); + + controller().init(const CatalogSearchQuery()); + await tester.pump(); + repo.complete(0, items: [game(1), game(2)], totalCount: 4); + await tester.pump(); + + controller().loadMore(); + await tester.pump(); + repo.complete(1, items: [game(2), game(3)], totalCount: 4); + await tester.pump(); + + expect(state().items.map((g) => g.id), ['game-1', 'game-2', 'game-3']); + }); + + run('a page without new items ends the list (no endless loading)', ( + tester, + ) async { + final repo = FakeCatalogRepository(manual: true); + useRepository(repo); + + controller().init(const CatalogSearchQuery()); + await tester.pump(); + repo.complete(0, items: [game(1), game(2)], totalCount: 5); + await tester.pump(); + + controller().loadMore(); + await tester.pump(); + repo.complete(1, items: const [], totalCount: 0); + await tester.pump(); + + expect(state().items, hasLength(2)); + expect(state().hasMore, isFalse); + expect(state().loadingMore, isFalse); + }); + + run('a new search discards a running loadMore', (tester) async { + final repo = FakeCatalogRepository(manual: true); + useRepository(repo); + + controller().init(const CatalogSearchQuery()); + await tester.pump(); + repo.complete(0, items: [game(1)], totalCount: 3); + await tester.pump(); + controller().loadMore(); + await tester.pump(); + + controller().submitText('neu'); + await tester.pump(); + repo.complete(1, items: [game(2), game(3)], totalCount: 3); + repo.complete(2, items: [game(9, title: 'Neu')], totalCount: 1); + await tester.pump(); + + expect(state().items.map((g) => g.title), ['Neu']); + expect(state().loadingMore, isFalse); + }); + + run('KAT-01 Kriterium 5: an error while loading more keeps the hits', ( + tester, + ) async { + controller().init(const CatalogSearchQuery()); + await tester.pump(); + + repository.failWith = const CatalogFailure.network(); + controller().loadMore(); + await tester.pump(); + + expect(state().items, hasLength(30)); + expect(state().status, CatalogSearchStatus.data); + expect(state().loadMoreFailure, isA()); + expect(state().loadingMore, isFalse); + + repository.failWith = null; + controller().retry(); + await tester.pump(); + + expect(state().items, hasLength(60)); + expect(state().loadMoreFailure, isNull); + }); + + run('Zustände Kriterium 3: an error keeps text and filters, retry works', ( + tester, + ) async { + repository.failWith = const CatalogFailure.network(); + controller().init( + const CatalogSearchQuery( + text: 'catan', + filters: CatalogFilters(players: 3), + ), + ); + await tester.pump(); + + expect(state().status, CatalogSearchStatus.error); + expect(state().failure, isA()); + expect(state().query.text, 'catan'); + expect(state().query.filters.players, 3); + + repository.failWith = null; + controller().retry(); + await tester.pump(); + + expect(state().status, CatalogSearchStatus.data); + expect(state().failure, isNull); + expect(repository.calls.last.query.text, 'catan'); + }); + + run('Zustände Kriterium 1: no hits give the empty status', (tester) async { + final repo = FakeCatalogRepository(); + useRepository(repo); + + + controller().init(const CatalogSearchQuery(text: 'zzz')); + await tester.pump(); + + expect(state().status, CatalogSearchStatus.empty); + expect(state().searchedQuery.text, 'zzz'); + }); + + run('a new search keeps the old hits visible while it runs', (tester) async { + final repo = FakeCatalogRepository(manual: true); + useRepository(repo); + + controller().init(const CatalogSearchQuery()); + await tester.pump(); + repo.complete(0, items: [game(1)], totalCount: 1); + await tester.pump(); + + controller().submitText('neu'); + await tester.pump(); + + expect(state().refreshing, isTrue); + expect(state().status, CatalogSearchStatus.data); + expect(state().items, hasLength(1)); + // The shown results still belong to the old query. + expect(state().searchedQuery.text, ''); + expect(state().query.text, 'neu'); + }); + + run('searchGeneration counts completed first-page searches only', ( + tester, + ) async { + controller().init(const CatalogSearchQuery()); + await tester.pump(); + expect(state().searchGeneration, 1); + + controller().loadMore(); + await tester.pump(); + expect(state().searchGeneration, 1); + + controller().submitText('abc'); + await tester.pump(); + expect(state().searchGeneration, 2); + }); +} diff --git a/app/test/features/catalog/presentation/search/catalog_search_view_test.dart b/app/test/features/catalog/presentation/search/catalog_search_view_test.dart new file mode 100644 index 0000000..e022363 --- /dev/null +++ b/app/test/features/catalog/presentation/search/catalog_search_view_test.dart @@ -0,0 +1,420 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shuffle/core/widgets/game_cover.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/presentation/search/catalog_search_controller.dart'; +import 'package:shuffle/features/catalog/presentation/search/catalog_search_view.dart'; + +import '../../../../helpers/pump_app.dart'; +import '../../catalog_test_support.dart'; + +/// Sets a logical window size for the test (device pixel ratio 1). +void useWindow(WidgetTester tester, double width, [double height = 900]) { + tester.view.physicalSize = Size(width, height); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); +} + +Future pumpView( + WidgetTester tester, + FakeCatalogRepository repository, { + CatalogSearchQuery initialQuery = const CatalogSearchQuery(), + ValueChanged? onReportGame, + Locale locale = const Locale('de'), + double? textScale, + double width = 420, + bool settle = true, +}) async { + useWindow(tester, width); + await pumpApp( + tester, + locale: locale, + textScale: textScale, + overrides: catalogOverrides(repository), + settle: false, + home: Scaffold( + body: CatalogSearchView( + initialQuery: initialQuery, + onReportGame: onReportGame, + ), + ), + ); + if (settle) { + await tester.pumpAndSettle(); + } +} + +Finder get searchInput => find.descendant( + of: find.byKey(const Key('catalog_search_field')), + matching: find.byType(TextField), +); + +void main() { + group('list and tiles', () { + testWidgets('KAT-01 Kriterium 8: starts with the whole catalog', ( + tester, + ) async { + final repository = FakeCatalogRepository(all: List.generate(75, game)); + + await pumpView(tester, repository); + + expect(find.byKey(const Key('catalog_tile_game-0')), findsOneWidget); + expect(find.text('75 Spiele'), findsOneWidget); + expect(repository.calls.single.query.isEmpty, isTrue); + }); + + testWidgets('KAT-01 Kriterium 1: tile shows facts, year and publisher', ( + tester, + ) async { + final repository = FakeCatalogRepository( + all: [game(1, title: 'Catan', minPlayers: 3, maxPlayers: 4)], + ); + + await pumpView(tester, repository); + + expect(find.text('Catan'), findsOneWidget); + expect(find.text('1995 · Kosmos'), findsOneWidget); + expect(find.text('3–4 Spieler · 60–120 Min. · ab 10'), findsOneWidget); + expect(find.text('1 Spiel'), findsOneWidget); + }); + + testWidgets('KAT-01 Kriterium 3: two publishers shown, more as +n', ( + tester, + ) async { + final repository = FakeCatalogRepository( + all: [ + game(1, publishers: ['A', 'B', 'C', 'D']), + ], + ); + + await pumpView(tester, repository); + + expect(find.text('1995 · A, B +2'), findsOneWidget); + }); + + testWidgets('KAT-01 Kriterium 1: missing facts are left out', ( + tester, + ) async { + final repository = FakeCatalogRepository( + all: [ + game( + 1, + year: null, + minPlayers: 2, + maxPlayers: 2, + minMinutes: null, + maxMinutes: 30, + minAge: null, + publishers: const [], + ), + ], + ); + + await pumpView(tester, repository); + + expect(find.text('2 Spieler · 30 Min.'), findsOneWidget); + expect(find.textContaining('ab '), findsNothing); + expect(find.textContaining('· '), findsNothing); + }); + + testWidgets('KAT-01 Kriterium 2: shows the matched alternative title', ( + tester, + ) async { + final repository = FakeCatalogRepository( + all: [ + game(1, title: 'Catan', matchedAltTitle: 'Die Siedler von Catan'), + ], + ); + + await pumpView(tester, repository); + + expect(find.text('auch: Die Siedler von Catan'), findsOneWidget); + }); + + testWidgets('KAT-05 Kriterium 10: tile shows the cover placeholder', ( + tester, + ) async { + final repository = FakeCatalogRepository(all: [game(1)]); + + await pumpView(tester, repository); + + final cover = tester.widget(find.byType(GameCover)); + expect(cover.imageUrl, isNull); + expect(cover.titleVisibleNearby, isTrue); + expect(find.byKey(const Key('game_cover_placeholder')), findsOneWidget); + }); + + testWidgets('a tile without onTap is not a button', (tester) async { + final repository = FakeCatalogRepository(all: [game(1)]); + final handle = tester.ensureSemantics(); + + await pumpView(tester, repository); + + final node = tester.getSemantics( + find.byKey(const Key('catalog_tile_game-1')), + ); + expect(node.label, contains('Spiel 1')); + expect(node.flagsCollection.isButton, isFalse); + handle.dispose(); + }); + + testWidgets('QUE-05 Kriterium 3: English texts and plural', (tester) async { + final repository = FakeCatalogRepository(all: [game(1)]); + + await pumpView(tester, repository, locale: const Locale('en')); + + expect(find.text('1 game'), findsOneWidget); + expect(find.text('3–4 players · 60–120 min · ages 10+'), findsOneWidget); + expect(find.text('Search games'), findsWidgets); + }); + }); + + group('search field', () { + testWidgets('KAT-01 Kriterium 1: typing searches after the debounce', ( + tester, + ) async { + final repository = FakeCatalogRepository(all: [game(1)]); + await pumpView(tester, repository); + repository.calls.clear(); + + await tester.enterText(searchInput, 'catan'); + await tester.pump(const Duration(milliseconds: 200)); + expect(repository.calls, isEmpty); + await tester.pump(const Duration(milliseconds: 150)); + + expect(repository.calls.single.query.text, 'catan'); + }); + + testWidgets('KAT-01 Kriterium 1: Enter searches without waiting', ( + tester, + ) async { + final repository = FakeCatalogRepository(all: [game(1)]); + await pumpView(tester, repository); + repository.calls.clear(); + + await tester.enterText(searchInput, 'catan'); + await tester.testTextInput.receiveAction(TextInputAction.search); + await tester.pump(); + + expect(repository.calls.single.query.text, 'catan'); + }); + + testWidgets('clear button empties the field and searches at once', ( + tester, + ) async { + final repository = FakeCatalogRepository(all: [game(1)]); + await pumpView( + tester, + repository, + initialQuery: const CatalogSearchQuery(text: 'catan'), + ); + expect(find.byKey(const Key('catalog_search_clear')), findsOneWidget); + repository.calls.clear(); + + await tester.tap(find.byKey(const Key('catalog_search_clear'))); + await tester.pump(); + + expect(repository.calls.single.query.text, ''); + expect(find.byKey(const Key('catalog_search_clear')), findsNothing); + expect(tester.widget(searchInput).controller!.text, ''); + }); + + testWidgets('the field starts with the text from the URL', (tester) async { + final repository = FakeCatalogRepository(all: [game(1)]); + + await pumpView( + tester, + repository, + initialQuery: const CatalogSearchQuery(text: 'catan'), + ); + + expect(tester.widget(searchInput).controller!.text, 'catan'); + expect(repository.calls.first.query.text, 'catan'); + }); + + testWidgets('Eingabegrenzen: the field accepts at most 100 characters', ( + tester, + ) async { + final repository = FakeCatalogRepository(); + await pumpView(tester, repository); + + await tester.enterText(searchInput, 'a' * 130); + await tester.pump(catalogSearchDebounce); + + expect( + tester.widget(searchInput).controller!.text, + hasLength(100), + ); + expect(repository.calls.last.query.text, hasLength(100)); + }); + + testWidgets('autofocus follows the parameter (web only by default)', ( + tester, + ) async { + final repository = FakeCatalogRepository(); + await pumpView(tester, repository); + + expect(tester.widget(searchInput).autofocus, isFalse); + }); + }); + + group('states', () { + testWidgets('Zustände Kriterium 1: loading shows an indicator', ( + tester, + ) async { + final repository = FakeCatalogRepository(manual: true); + + await pumpView(tester, repository, settle: false); + await tester.pump(); + + expect(find.byKey(const Key('catalog_loading')), findsOneWidget); + // Search field and filter button stay usable. + expect(searchInput, findsOneWidget); + expect(find.byKey(const Key('catalog_filter_button')), findsOneWidget); + }); + + testWidgets('a new search keeps the old hits and shows a thin bar', ( + tester, + ) async { + final repository = FakeCatalogRepository(manual: true); + await pumpView(tester, repository, settle: false); + await tester.pump(); + repository.complete(0, items: [game(1)]); + await tester.pump(); + + await tester.enterText(searchInput, 'neu'); + await tester.testTextInput.receiveAction(TextInputAction.search); + await tester.pump(); + + expect(find.text('Spiel 1'), findsOneWidget); + expect(find.byKey(const Key('catalog_refreshing')), findsOneWidget); + }); + + testWidgets('KAT-01 Kriterium 6: no hits for a text give the hint', ( + tester, + ) async { + final repository = FakeCatalogRepository(); + + await pumpView( + tester, + repository, + initialQuery: const CatalogSearchQuery(text: 'zzz'), + ); + + expect(find.text('Keine Spiele zu „zzz“ gefunden.'), findsOneWidget); + expect( + find.text('Prüfe die Schreibweise oder entferne Filter.'), + findsOneWidget, + ); + // #3 passes no callback: no report action, no reset without filters. + expect(find.byKey(const Key('catalog_empty_report')), findsNothing); + expect(find.byKey(const Key('catalog_empty_reset')), findsNothing); + }); + + testWidgets('KAT-01 Kriterium 6: onReportGame adds the action', ( + tester, + ) async { + final repository = FakeCatalogRepository(); + String? reported; + + await pumpView( + tester, + repository, + initialQuery: const CatalogSearchQuery(text: 'zzz'), + onReportGame: (text) => reported = text, + ); + await tester.tap(find.byKey(const Key('catalog_empty_report'))); + + expect(reported, 'zzz'); + expect(find.text('Fehlendes Spiel melden'), findsOneWidget); + }); + + testWidgets('KAT-01 Kriterium 6: reset is offered with active filters', ( + tester, + ) async { + final repository = FakeCatalogRepository(); + + await pumpView( + tester, + repository, + initialQuery: const CatalogSearchQuery( + text: 'zzz', + filters: CatalogFilters(players: 3), + ), + ); + repository.calls.clear(); + repository.all = [game(1)]; + await tester.tap(find.byKey(const Key('catalog_empty_reset'))); + await tester.pumpAndSettle(); + + expect(repository.calls.single.query.text, 'zzz'); + expect(repository.calls.single.query.hasFilters, isFalse); + }); + + testWidgets('KAT-01 Kriterium 8: no hits for filters only', (tester) async { + final repository = FakeCatalogRepository(); + + await pumpView( + tester, + repository, + initialQuery: const CatalogSearchQuery( + filters: CatalogFilters(players: 3), + ), + ); + + expect( + find.text('Keine Spiele passen zu deinen Filtern.'), + findsOneWidget, + ); + expect(find.byKey(const Key('catalog_empty_reset')), findsOneWidget); + expect(find.byKey(const Key('catalog_empty_report')), findsNothing); + }); + + testWidgets('an empty catalog says so', (tester) async { + final repository = FakeCatalogRepository(); + + await pumpView(tester, repository); + + expect(find.text('Der Katalog ist noch leer.'), findsOneWidget); + }); + + testWidgets('Zustände Kriterium 3: offline shows the hint and retries', ( + tester, + ) async { + final repository = FakeCatalogRepository(all: [game(1)]) + ..failWith = const CatalogFailure.network(); + await pumpView( + tester, + repository, + initialQuery: const CatalogSearchQuery(text: 'catan'), + ); + expect( + find.text('Keine Verbindung. Für die Suche brauchst du Internet.'), + findsOneWidget, + ); + // Text and filters survive the error. + expect(tester.widget(searchInput).controller!.text, 'catan'); + + repository.failWith = null; + await tester.tap(find.byKey(const Key('catalog_error_retry'))); + await tester.pumpAndSettle(); + + expect(find.text('Spiel 1'), findsOneWidget); + expect(find.byKey(const Key('catalog_error')), findsNothing); + }); + + testWidgets('Zustände Kriterium 2: other errors show the generic text', ( + tester, + ) async { + final repository = FakeCatalogRepository() + ..failWith = const CatalogFailure.unexpected(); + + await pumpView(tester, repository); + + expect(find.text('Die Suche ist gerade nicht möglich.'), findsOneWidget); + expect(find.byKey(const Key('catalog_error_retry')), findsOneWidget); + }); + }); +} From 28404edd10d2cdcc1b46cde7311795e459ad252c Mon Sep 17 00:00:00 2001 From: cschlaefke Date: Wed, 4 Nov 2026 01:18:23 +0100 Subject: [PATCH 2/8] fix(catalog): check auto-load also on size changes Refs #3 Co-Authored-By: Claude Sonnet 5.5 --- .../catalog/presentation/search/catalog_search_view.dart | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) 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 4512c6d..c4a112e 100644 --- a/app/lib/features/catalog/presentation/search/catalog_search_view.dart +++ b/app/lib/features/catalog/presentation/search/catalog_search_view.dart @@ -245,12 +245,14 @@ class _CatalogSearchViewState extends ConsumerState { ); final state = ref.watch(catalogSearchControllerProvider); _scheduleFocus(state); - if (state.status == CatalogSearchStatus.data) { - _scheduleAutoLoadCheck(); - } return LayoutBuilder( builder: (context, constraints) { + // Also on a size change (without a state change): a larger window + // may show the whole list, so no scroll event would ever come. + if (state.status == CatalogSearchStatus.data) { + _scheduleAutoLoadCheck(); + } final width = constraints.maxWidth; final expanded = AppWidthClass.of(width) == AppWidthClass.expanded; final padding = AppSpacing.pagePadding(width); From 3b66f4afb0d3cd6bd60af57bca1d0e0646ae9b0c Mon Sep 17 00:00:00 2001 From: cschlaefke Date: Wed, 4 Nov 2026 01:18:23 +0100 Subject: [PATCH 3/8] test(catalog): widget, a11y, URL sync and controller tests for catalog search KAT-01 Kriterium 1-6 and 8, QUE-05, QUE-06. Refs #3 Co-Authored-By: Claude Sonnet 5.5 --- .../core/widgets/app_state_message_test.dart | 190 +++++++++ .../supabase_catalog_repository_test.dart | 2 +- .../search/catalog_search_a11y_test.dart | 162 ++++++++ .../search/catalog_search_behavior_test.dart | 362 ++++++++++++++++++ .../catalog_search_controller_test.dart | 41 +- .../search/catalog_search_screen_test.dart | 207 ++++++++++ .../search/catalog_search_view_test.dart | 1 - 7 files changed, 942 insertions(+), 23 deletions(-) create mode 100644 app/test/core/widgets/app_state_message_test.dart create mode 100644 app/test/features/catalog/presentation/search/catalog_search_a11y_test.dart create mode 100644 app/test/features/catalog/presentation/search/catalog_search_behavior_test.dart create mode 100644 app/test/features/catalog/presentation/search/catalog_search_screen_test.dart diff --git a/app/test/core/widgets/app_state_message_test.dart b/app/test/core/widgets/app_state_message_test.dart new file mode 100644 index 0000000..ad570f1 --- /dev/null +++ b/app/test/core/widgets/app_state_message_test.dart @@ -0,0 +1,190 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shuffle/core/widgets/app_list_footer.dart'; +import 'package:shuffle/core/widgets/app_state_message.dart'; + +import '../../helpers/pump_app.dart'; + +void main() { + group('AppStateMessage', () { + Future pumpMessage( + WidgetTester tester, { + String? message, + List actions = const [], + ThemeMode themeMode = ThemeMode.light, + double? textScale, + }) => pumpApp( + tester, + themeMode: themeMode, + textScale: textScale, + home: Scaffold( + body: AppStateMessage( + icon: Icons.search_off, + title: 'Nichts gefunden', + message: message, + actions: actions, + ), + ), + ); + + testWidgets('shows title, hint and actions', (tester) async { + var pressed = 0; + await pumpMessage( + tester, + message: 'Prüfe die Schreibweise.', + actions: [ + AppStateAction( + key: const Key('first'), + label: 'Erneut versuchen', + onPressed: () => pressed++, + ), + AppStateAction(label: 'Zurücksetzen', onPressed: () {}), + ], + ); + + expect(find.text('Nichts gefunden'), findsOneWidget); + expect(find.text('Prüfe die Schreibweise.'), findsOneWidget); + await tester.tap(find.byKey(const Key('first'))); + expect(pressed, 1); + expect(find.text('Zurücksetzen'), findsOneWidget); + }); + + testWidgets('without hint and actions it is just icon and title', ( + tester, + ) async { + await pumpMessage(tester); + + expect(find.byType(FilledButton), findsNothing); + expect(find.byType(OutlinedButton), findsNothing); + }); + + for (final mode in ThemeMode.values.where((m) => m != ThemeMode.system)) { + testWidgets('meets accessibility guidelines ($mode)', (tester) async { + final handle = tester.ensureSemantics(); + await pumpMessage( + tester, + message: 'Hinweis', + themeMode: mode, + actions: [ + AppStateAction(label: 'Erneut versuchen', onPressed: () {}), + AppStateAction(label: 'Zurücksetzen', onPressed: () {}), + ], + ); + + await expectLater(tester, meetsGuideline(androidTapTargetGuideline)); + await expectLater(tester, meetsGuideline(labeledTapTargetGuideline)); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + handle.dispose(); + }); + } + + testWidgets('text scale 2.0 does not overflow', (tester) async { + await pumpMessage( + tester, + message: 'Ein längerer Hinweis, der umbrechen muss.', + textScale: 2, + actions: [AppStateAction(label: 'Erneut versuchen', onPressed: () {})], + ); + + expect(tester.takeException(), isNull); + }); + }); + + group('AppListFooter', () { + Future pumpFooter( + WidgetTester tester, { + bool isLoading = false, + bool hasMore = false, + String? errorMessage, + VoidCallback? onLoadMore, + VoidCallback? onRetry, + }) => pumpApp( + tester, + home: Scaffold( + body: AppListFooter( + isLoading: isLoading, + hasMore: hasMore, + errorMessage: errorMessage, + loadingLabel: 'Lädt', + loadMoreLabel: 'Weitere laden', + retryLabel: 'Erneut versuchen', + onLoadMore: onLoadMore ?? () {}, + onRetry: onRetry ?? () {}, + ), + ), + settle: false, + ); + + testWidgets('shows the load-more button while more is available', ( + tester, + ) async { + var loads = 0; + await pumpFooter(tester, hasMore: true, onLoadMore: () => loads++); + + await tester.tap(find.text('Weitere laden')); + + expect(loads, 1); + }); + + testWidgets('shows a labeled indicator while loading', (tester) async { + final handle = tester.ensureSemantics(); + await pumpFooter(tester, hasMore: true, isLoading: true); + + expect(find.byKey(const Key('list_footer_loading')), findsOneWidget); + expect(find.text('Weitere laden'), findsNothing); + expect(find.bySemanticsLabel('Lädt'), findsOneWidget); + handle.dispose(); + }); + + testWidgets('shows the error with retry, ahead of everything else', ( + tester, + ) async { + var retries = 0; + await pumpFooter( + tester, + hasMore: true, + isLoading: true, + errorMessage: 'Fehler beim Laden', + onRetry: () => retries++, + ); + + expect(find.text('Fehler beim Laden'), findsOneWidget); + await tester.tap(find.byKey(const Key('list_footer_retry'))); + expect(retries, 1); + expect(find.byKey(const Key('list_footer_loading')), findsNothing); + }); + + testWidgets('shows nothing at the end of the list', (tester) async { + await pumpFooter(tester); + + expect(find.byType(OutlinedButton), findsNothing); + expect(find.byKey(const Key('list_footer_loading')), findsNothing); + }); + + testWidgets('the load-more button takes a focus node', (tester) async { + final node = FocusNode(); + addTearDown(node.dispose); + await pumpApp( + tester, + home: Scaffold( + body: AppListFooter( + isLoading: false, + hasMore: true, + loadingLabel: 'Lädt', + loadMoreLabel: 'Weitere laden', + retryLabel: 'Erneut versuchen', + onLoadMore: () {}, + onRetry: () {}, + loadMoreFocusNode: node, + ), + ), + settle: false, + ); + + node.requestFocus(); + await tester.pump(); + + expect(node.hasPrimaryFocus, isTrue); + }); + }); +} diff --git a/app/test/features/catalog/data/supabase_catalog_repository_test.dart b/app/test/features/catalog/data/supabase_catalog_repository_test.dart index 5f396f9..706e443 100644 --- a/app/test/features/catalog/data/supabase_catalog_repository_test.dart +++ b/app/test/features/catalog/data/supabase_catalog_repository_test.dart @@ -6,10 +6,10 @@ import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:logging/logging.dart'; import 'package:shuffle/core/logging/app_logger.dart'; +import 'package:shuffle/features/catalog/data/supabase_catalog_repository.dart'; import 'package:shuffle/features/catalog/domain/catalog_failure.dart'; import 'package:shuffle/features/catalog/domain/catalog_search_query.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; -import 'package:shuffle/features/catalog/data/supabase_catalog_repository.dart'; import '../catalog_test_support.dart'; diff --git a/app/test/features/catalog/presentation/search/catalog_search_a11y_test.dart b/app/test/features/catalog/presentation/search/catalog_search_a11y_test.dart new file mode 100644 index 0000000..f8dd4f8 --- /dev/null +++ b/app/test/features/catalog/presentation/search/catalog_search_a11y_test.dart @@ -0,0 +1,162 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shuffle/features/catalog/domain/catalog_search_query.dart'; + +import '../../catalog_test_support.dart'; +import 'catalog_search_view_test.dart'; + +void main() { + group('announcements', () { + testWidgets('QUE-06 Kriterium 2: the hit count is announced once', ( + tester, + ) async { + final repository = FakeCatalogRepository(all: List.generate(75, game)); + await pumpView(tester, repository); + tester.takeAnnouncements(); + + await tester.enterText(searchInput, 'spiel'); + await tester.testTextInput.receiveAction(TextInputAction.search); + await tester.pump(); + // Not at once: the announcement waits for a quiet moment. + expect(tester.takeAnnouncements(), isEmpty); + + await tester.pump(const Duration(seconds: 1)); + + final messages = tester.takeAnnouncements().map((a) => a.message); + expect(messages, ['75 Spiele gefunden']); + }); + + testWidgets('QUE-06 Kriterium 2: nothing is announced while typing', ( + tester, + ) async { + final repository = FakeCatalogRepository(all: List.generate(75, game)); + await pumpView(tester, repository); + await tester.pump(const Duration(seconds: 1)); + tester.takeAnnouncements(); + + await tester.enterText(searchInput, 'sp'); + await tester.pump(const Duration(milliseconds: 100)); + await tester.enterText(searchInput, 'spi'); + await tester.pump(const Duration(milliseconds: 100)); + + expect(tester.takeAnnouncements(), isEmpty); + // Let the pending search run out before the test ends. + await tester.pump(const Duration(seconds: 2)); + }); + + testWidgets('quick filter changes give one announcement (debounced)', ( + tester, + ) async { + final repository = FakeCatalogRepository(all: [game(1)]); + await pumpView(tester, repository); + await tester.pump(const Duration(seconds: 1)); + tester.takeAnnouncements(); + await tester.tap(find.byKey(const Key('catalog_filter_button'))); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('catalog_term_$genreStrategy'))); + await tester.pump(const Duration(milliseconds: 100)); + await tester.tap(find.byKey(const Key('catalog_term_$genreFamily'))); + await tester.pump(const Duration(milliseconds: 100)); + await tester.tap(find.byKey(const Key('catalog_term_$mechanicDice'))); + await tester.pump(const Duration(seconds: 1)); + + final messages = tester.takeAnnouncements().map((a) => a.message); + expect(messages, ['1 Spiel gefunden']); + }); + + testWidgets('an empty result is announced as well', (tester) async { + final repository = FakeCatalogRepository(); + await pumpView( + tester, + repository, + initialQuery: const CatalogSearchQuery(text: 'zzz'), + ); + + await tester.pump(const Duration(seconds: 1)); + + final messages = tester.takeAnnouncements().map((a) => a.message); + expect(messages, contains('Keine Spiele gefunden')); + }); + + testWidgets('English announcement', (tester) async { + final repository = FakeCatalogRepository(all: List.generate(2, game)); + await pumpView(tester, repository, locale: const Locale('en')); + + await tester.pump(const Duration(seconds: 1)); + + final messages = tester.takeAnnouncements().map((a) => a.message); + expect(messages, contains('2 games found')); + }); + }); + + group('accessibility guidelines (QUE-06 Kriterium 1, 3)', () { + Future check(WidgetTester tester, {double width = 420}) async { + final handle = tester.ensureSemantics(); + final repository = FakeCatalogRepository( + all: [ + game(1, matchedAltTitle: 'Alternativtitel'), + game(2, publishers: ['A', 'B', 'C']), + ], + ); + await pumpView( + tester, + repository, + width: width, + initialQuery: const CatalogSearchQuery(text: 'spiel'), + ); + + await expectLater(tester, meetsGuideline(labeledTapTargetGuideline)); + await expectLater(tester, meetsGuideline(androidTapTargetGuideline)); + await expectLater(tester, meetsGuideline(iOSTapTargetGuideline)); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + handle.dispose(); + } + + testWidgets('compact layout', check); + testWidgets('expanded layout with side bar', (tester) async { + await check(tester, width: 1200); + }); + + testWidgets('filter sheet', (tester) async { + final handle = tester.ensureSemantics(); + final repository = FakeCatalogRepository(all: [game(1)]); + await pumpView(tester, repository); + await tester.tap(find.byKey(const Key('catalog_filter_button'))); + await tester.pumpAndSettle(); + + await expectLater(tester, meetsGuideline(labeledTapTargetGuideline)); + await expectLater(tester, meetsGuideline(androidTapTargetGuideline)); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + handle.dispose(); + }); + + testWidgets('empty and error states', (tester) async { + final handle = tester.ensureSemantics(); + final repository = FakeCatalogRepository(); + await pumpView( + tester, + repository, + initialQuery: const CatalogSearchQuery( + text: 'zzz', + filters: CatalogFilters(players: 2), + ), + ); + + await expectLater(tester, meetsGuideline(androidTapTargetGuideline)); + await expectLater(tester, meetsGuideline(labeledTapTargetGuideline)); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + handle.dispose(); + }); + + testWidgets('search field is labeled for screen readers', (tester) async { + final handle = tester.ensureSemantics(); + final repository = FakeCatalogRepository(all: [game(1)]); + await pumpView(tester, repository); + + expect(find.bySemanticsLabel('Spiele suchen'), findsWidgets); + expect(find.bySemanticsLabel('Suchbegriff löschen'), findsNothing); + handle.dispose(); + }); + }); +} diff --git a/app/test/features/catalog/presentation/search/catalog_search_behavior_test.dart b/app/test/features/catalog/presentation/search/catalog_search_behavior_test.dart new file mode 100644 index 0000000..4fdbcb8 --- /dev/null +++ b/app/test/features/catalog/presentation/search/catalog_search_behavior_test.dart @@ -0,0 +1,362 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shuffle/features/catalog/domain/catalog_failure.dart'; +import 'package:shuffle/features/catalog/domain/catalog_search_query.dart'; + +import '../../catalog_test_support.dart'; +import 'catalog_search_view_test.dart'; + +Future openFilterSheet(WidgetTester tester) async { + await tester.tap(find.byKey(const Key('catalog_filter_button'))); + await tester.pumpAndSettle(); +} + +void main() { + group('filters in the bottom sheet (compact)', () { + testWidgets('KAT-01 Kriterium 4: the button shows the number of filters', ( + tester, + ) async { + final repository = FakeCatalogRepository(all: [game(1)]); + + await pumpView( + tester, + repository, + initialQuery: const CatalogSearchQuery( + filters: CatalogFilters(players: 2, genreIds: {genreFamily}), + ), + ); + + expect(find.text('Filter (2)'), findsOneWidget); + }); + + testWidgets('the button shows plain "Filter" without filters', ( + tester, + ) async { + final repository = FakeCatalogRepository(all: [game(1)]); + + await pumpView(tester, repository); + + expect(find.text('Filter'), findsOneWidget); + }); + + testWidgets('KAT-01 Kriterium 4: each filter kind sets its value', ( + tester, + ) async { + final repository = FakeCatalogRepository(all: [game(1)]); + await pumpView(tester, repository); + await openFilterSheet(tester); + + Future pick(String fieldKey, String label) async { + await tester.tap(find.byKey(Key(fieldKey))); + await tester.pumpAndSettle(); + await tester.tap(find.text(label).last); + await tester.pumpAndSettle(); + } + + await pick('catalog_filter_players', '4 Spieler'); + await pick('catalog_filter_duration', 'Bis 60 Min.'); + await pick('catalog_filter_age', 'Ab 10 Jahren geeignet'); + await tester.tap(find.byKey(const Key('catalog_term_$genreStrategy'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('catalog_term_$mechanicDice'))); + await tester.pumpAndSettle(); + + final filters = repository.calls.last.query.filters; + expect(filters.players, 4); + expect(filters.maxMinutes, 60); + expect(filters.minAgeSuitable, 10); + expect(filters.genreIds, {genreStrategy}); + expect(filters.mechanicIds, {mechanicDice}); + // The list behind the sheet updates immediately. + expect(repository.calls.length, greaterThan(5)); + }); + + testWidgets('KAT-01 Kriterium 4: two genres are both sent (AND)', ( + tester, + ) async { + final repository = FakeCatalogRepository(all: [game(1)]); + await pumpView(tester, repository); + await openFilterSheet(tester); + + await tester.tap(find.byKey(const Key('catalog_term_$genreStrategy'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('catalog_term_$genreFamily'))); + await tester.pumpAndSettle(); + + expect(repository.calls.last.query.filters.genreIds, { + genreStrategy, + genreFamily, + }); + }); + + testWidgets('KAT-01 Kriterium 4: a chip can be deselected again', ( + tester, + ) async { + final repository = FakeCatalogRepository(all: [game(1)]); + await pumpView(tester, repository); + await openFilterSheet(tester); + + await tester.tap(find.byKey(const Key('catalog_term_$genreStrategy'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('catalog_term_$genreStrategy'))); + await tester.pumpAndSettle(); + + expect(repository.calls.last.query.filters.genreIds, isEmpty); + }); + + testWidgets('reset removes all filters and Done closes the sheet', ( + tester, + ) async { + final repository = FakeCatalogRepository(all: [game(1)]); + await pumpView( + tester, + repository, + initialQuery: const CatalogSearchQuery( + filters: CatalogFilters(players: 2, genreIds: {genreFamily}), + ), + ); + await openFilterSheet(tester); + + await tester.tap(find.byKey(const Key('catalog_filter_reset'))); + await tester.pumpAndSettle(); + expect(repository.calls.last.query.hasFilters, isFalse); + + await tester.tap(find.byKey(const Key('catalog_filter_done'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('catalog_filter_sheet')), findsNothing); + expect(find.text('Filter'), findsWidgets); + }); + + testWidgets('QUE-05 Kriterium 4: terms are named and sorted by language', ( + tester, + ) async { + final repository = FakeCatalogRepository(all: [game(1)]); + await pumpView(tester, repository); + await openFilterSheet(tester); + + // German: Familie before Strategie. + expect(find.text('Familie'), findsOneWidget); + final de = tester.getTopLeft(find.text('Familie')).dx; + final de2 = tester.getTopLeft(find.text('Strategie')).dx; + expect(de, lessThan(de2)); + }); + + testWidgets('QUE-05 Kriterium 4: English names and order', (tester) async { + final repository = FakeCatalogRepository(all: [game(1)]); + await pumpView(tester, repository, locale: const Locale('en')); + await openFilterSheet(tester); + + expect(find.text('Dice rolling'), findsOneWidget); + expect(find.text('Strategy'), findsOneWidget); + final family = tester.getTopLeft(find.text('Family')).dx; + final strategy = tester.getTopLeft(find.text('Strategy')).dx; + expect(family, lessThan(strategy)); + }); + + testWidgets('a vocabulary error shows a hint with retry, rest works', ( + tester, + ) async { + final repository = FakeCatalogRepository(all: [game(1)]) + ..termsFailWith = const CatalogFailure.network(); + await pumpView(tester, repository); + await openFilterSheet(tester); + + expect( + find.text('Die Auswahl konnte nicht geladen werden.'), + findsNWidgets(2), + ); + // The other filters still work. + await tester.tap(find.byKey(const Key('catalog_filter_players'))); + await tester.pumpAndSettle(); + await tester.tap(find.text('2 Spieler').last); + await tester.pumpAndSettle(); + expect(repository.calls.last.query.filters.players, 2); + + repository.termsFailWith = null; + await tester.tap(find.byKey(const Key('catalog_terms_retry')).first); + await tester.pumpAndSettle(); + expect( + find.byKey(const Key('catalog_term_$genreStrategy')), + findsOneWidget, + ); + }); + }); + + group('keyboard', () { + testWidgets('QUE-06 Kriterium 4: Tab leads from the field to the filters', ( + tester, + ) async { + final repository = FakeCatalogRepository(all: [game(1)]); + await pumpView(tester, repository); + + await tester.tap(searchInput); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pump(); + final focused = FocusManager.instance.primaryFocus!.context; + expect( + find.descendant( + of: find.byKey(const Key('catalog_filter_button')), + matching: find.byElementPredicate((e) => e == focused), + ), + findsOneWidget, + ); + }); + }); + + group('layout', () { + testWidgets('Plattformen Kriterium 2: side bar from 1024 dp', ( + tester, + ) async { + final repository = FakeCatalogRepository(all: [game(1)]); + + await pumpView(tester, repository, width: 1200); + + expect(find.byKey(const Key('catalog_filter_sidebar')), findsOneWidget); + expect(find.byKey(const Key('catalog_filter_button')), findsNothing); + // The filters work without a sheet. + await tester.tap(find.byKey(const Key('catalog_term_$genreFamily'))); + await tester.pumpAndSettle(); + expect(repository.calls.last.query.filters.genreIds, {genreFamily}); + }); + + testWidgets('below 1024 dp there is no side bar', (tester) async { + final repository = FakeCatalogRepository(all: [game(1)]); + + await pumpView(tester, repository, width: 800); + + expect(find.byKey(const Key('catalog_filter_sidebar')), findsNothing); + expect(find.byKey(const Key('catalog_filter_button')), findsOneWidget); + }); + + for (final width in [420.0, 800.0, 1200.0]) { + testWidgets('QUE-06 Kriterium 3: text scale 2.0 at $width dp', ( + tester, + ) async { + final repository = FakeCatalogRepository( + all: [ + game( + 1, + title: 'Ein sehr langer Spieltitel für den Test', + matchedAltTitle: 'Noch ein langer alternativer Titel', + ), + game(2, publishers: ['Verlag A', 'Verlag B', 'Verlag C']), + ], + ); + await pumpView(tester, repository, width: width, textScale: 2.0); + + expect(tester.takeException(), isNull); + if (width < 1024) { + await openFilterSheet(tester); + expect(tester.takeException(), isNull); + } + }); + } + }); + + group('paging', () { + testWidgets( + 'KAT-01 Kriterium 5: scrolling to the end loads the next page', + (tester) async { + final repository = FakeCatalogRepository(all: List.generate(75, game)); + await pumpView(tester, repository); + expect(repository.calls, hasLength(1)); + + await tester.drag( + find.byKey(const Key('catalog_result_list')), + const Offset(0, -3000), + ); + await tester.pumpAndSettle(); + + expect(repository.calls.length, greaterThanOrEqualTo(2)); + expect(repository.calls[1].offset, 30); + expect(repository.calls[1].query.isEmpty, isTrue); + }, + ); + + testWidgets('a first page that does not fill the screen loads on', ( + tester, + ) async { + final repository = FakeCatalogRepository(all: List.generate(75, game)); + await pumpView(tester, repository); + repository.calls.clear(); + + // No scroll event can come when everything is visible at once. + tester.view.physicalSize = const Size(420, 9000); + await tester.pumpAndSettle(); + + expect(repository.calls, isNotEmpty); + expect(repository.calls.first.offset, 30); + }); + + testWidgets('the end of the list shows no more button', (tester) async { + final repository = FakeCatalogRepository(all: List.generate(5, game)); + + await pumpView(tester, repository); + + expect(repository.calls, hasLength(1)); + expect(find.byKey(const Key('list_footer_load_more')), findsNothing); + expect(find.byKey(const Key('list_footer_loading')), findsNothing); + }); + + testWidgets( + 'KAT-01 Kriterium 5: a failed load keeps the hits; retry moves focus ' + 'to the first new tile (QUE-06 Kriterium 4)', + (tester) async { + final repository = FakeCatalogRepository(manual: true); + await pumpView(tester, repository, settle: false); + await tester.pump(); + repository.complete(0, items: List.generate(30, game), totalCount: 90); + await tester.pump(); + + await tester.drag( + find.byKey(const Key('catalog_result_list')), + const Offset(0, -3000), + ); + await tester.pump(); + expect(repository.calls, hasLength(2)); + expect(find.byKey(const Key('list_footer_loading')), findsOneWidget); + + repository.complete(1, failure: const CatalogFailure.network()); + await tester.pumpAndSettle(); + await tester.drag( + find.byKey(const Key('catalog_result_list')), + const Offset(0, -3000), + ); + await tester.pumpAndSettle(); + + expect( + find.text('Weitere Treffer konnten nicht geladen werden.'), + findsOneWidget, + ); + expect(find.byKey(const Key('catalog_tile_game-29')), findsOneWidget); + // No automatic retry loop. + expect(repository.calls, hasLength(2)); + + await tester.tap(find.byKey(const Key('list_footer_retry'))); + await tester.pump(); + expect(repository.calls, hasLength(3)); + expect(repository.calls[2].offset, 30); + repository.complete( + 2, + items: List.generate(30, (i) => game(30 + i)), + totalCount: 90, + ); + await tester.pumpAndSettle(); + + final tile = find.byKey(const Key('catalog_tile_game-30')); + expect(tile, findsOneWidget); + expect( + find.ancestor( + of: tile, + matching: find.byWidgetPredicate( + (w) => w is Focus && (w.focusNode?.hasPrimaryFocus ?? false), + ), + ), + findsOneWidget, + ); + }, + ); + }); +} diff --git a/app/test/features/catalog/presentation/search/catalog_search_controller_test.dart b/app/test/features/catalog/presentation/search/catalog_search_controller_test.dart index 5d2e7f3..d3b1637 100644 --- a/app/test/features/catalog/presentation/search/catalog_search_controller_test.dart +++ b/app/test/features/catalog/presentation/search/catalog_search_controller_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shuffle/features/catalog/domain/catalog_failure.dart'; @@ -125,7 +127,6 @@ void main() { run('KAT-01 Kriterium 1: only the latest request counts', (tester) async { final repo = FakeCatalogRepository(manual: true); useRepository(repo); - controller().init(const CatalogSearchQuery()); await tester.pump(); @@ -151,7 +152,7 @@ void main() { ) async { controller().init(const CatalogSearchQuery()); await tester.pump(); - controller().loadMore(); + unawaited(controller().loadMore()); await tester.pump(); repository.calls.clear(); @@ -200,20 +201,20 @@ void main() { controller().init(const CatalogSearchQuery()); await tester.pump(); - controller().loadMore(); + unawaited(controller().loadMore()); await tester.pump(); expect(state().items, hasLength(60)); expect(repository.calls.last.offset, 30); expect(state().hasMore, isTrue); - controller().loadMore(); + unawaited(controller().loadMore()); await tester.pump(); expect(state().items, hasLength(75)); expect(state().hasMore, isFalse); // Nothing more to load. - controller().loadMore(); + unawaited(controller().loadMore()); await tester.pump(); expect(repository.calls, hasLength(3)); }); @@ -226,15 +227,14 @@ void main() { manual: true, ); useRepository(repo); - + controller().init(const CatalogSearchQuery()); await tester.pump(); repo.complete(0, items: repo.all.sublist(0, 30), totalCount: 75); await tester.pump(); - controller() - ..loadMore() - ..loadMore(); + unawaited(controller().loadMore()); + unawaited(controller().loadMore()); await tester.pump(); expect(repo.calls, hasLength(2)); @@ -246,13 +246,13 @@ void main() { ) async { final repo = FakeCatalogRepository(manual: true); useRepository(repo); - + controller().init(const CatalogSearchQuery()); await tester.pump(); repo.complete(0, items: [game(1), game(2)], totalCount: 4); await tester.pump(); - controller().loadMore(); + unawaited(controller().loadMore()); await tester.pump(); repo.complete(1, items: [game(2), game(3)], totalCount: 4); await tester.pump(); @@ -265,13 +265,13 @@ void main() { ) async { final repo = FakeCatalogRepository(manual: true); useRepository(repo); - + controller().init(const CatalogSearchQuery()); await tester.pump(); repo.complete(0, items: [game(1), game(2)], totalCount: 5); await tester.pump(); - controller().loadMore(); + unawaited(controller().loadMore()); await tester.pump(); repo.complete(1, items: const [], totalCount: 0); await tester.pump(); @@ -284,12 +284,12 @@ void main() { run('a new search discards a running loadMore', (tester) async { final repo = FakeCatalogRepository(manual: true); useRepository(repo); - + controller().init(const CatalogSearchQuery()); await tester.pump(); repo.complete(0, items: [game(1)], totalCount: 3); await tester.pump(); - controller().loadMore(); + unawaited(controller().loadMore()); await tester.pump(); controller().submitText('neu'); @@ -309,7 +309,7 @@ void main() { await tester.pump(); repository.failWith = const CatalogFailure.network(); - controller().loadMore(); + unawaited(controller().loadMore()); await tester.pump(); expect(state().items, hasLength(30)); @@ -318,7 +318,7 @@ void main() { expect(state().loadingMore, isFalse); repository.failWith = null; - controller().retry(); + unawaited(controller().retry()); await tester.pump(); expect(state().items, hasLength(60)); @@ -343,7 +343,7 @@ void main() { expect(state().query.filters.players, 3); repository.failWith = null; - controller().retry(); + unawaited(controller().retry()); await tester.pump(); expect(state().status, CatalogSearchStatus.data); @@ -354,7 +354,6 @@ void main() { run('Zustände Kriterium 1: no hits give the empty status', (tester) async { final repo = FakeCatalogRepository(); useRepository(repo); - controller().init(const CatalogSearchQuery(text: 'zzz')); await tester.pump(); @@ -366,7 +365,7 @@ void main() { run('a new search keeps the old hits visible while it runs', (tester) async { final repo = FakeCatalogRepository(manual: true); useRepository(repo); - + controller().init(const CatalogSearchQuery()); await tester.pump(); repo.complete(0, items: [game(1)], totalCount: 1); @@ -390,7 +389,7 @@ void main() { await tester.pump(); expect(state().searchGeneration, 1); - controller().loadMore(); + unawaited(controller().loadMore()); await tester.pump(); expect(state().searchGeneration, 1); 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 new file mode 100644 index 0000000..f9398f0 --- /dev/null +++ b/app/test/features/catalog/presentation/search/catalog_search_screen_test.dart @@ -0,0 +1,207 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:shuffle/core/routing/app_router.dart'; +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/presentation/catalog_routes.dart'; +import 'package:shuffle/features/catalog/presentation/search/catalog_search_view.dart'; + +import '../../../../helpers/pump_app.dart'; +import '../../../../helpers/router_helpers.dart'; +import '../../catalog_test_support.dart'; +import 'catalog_search_view_test.dart'; + +void main() { + group('route /catalog', () { + testWidgets('Plattformen: /catalog is public (no sign-in redirect)', ( + tester, + ) async { + expect( + authRedirectFor( + const SignedOut(), + Uri.parse('/catalog?q=catan'), + privatePaths: const [], + ), + isNull, + ); + expect( + authRedirectFor( + const SignedIn( + AuthUser(id: 'u1', email: 'a@example.com', emailConfirmed: true), + ), + Uri.parse('/catalog'), + ), + isNull, + ); + }); + + testWidgets('KAT-01 Kriterium 1: the URL parameters are the start values', ( + tester, + ) async { + useWindow(tester, 420); + final repository = FakeCatalogRepository(all: [game(1)]); + final container = await pumpShuffleApp( + tester, + overrides: catalogOverrides(repository), + ); + + container + .read(appRouterProvider) + .go('/catalog?q=catan&players=3&maxMinutes=60&genre=$genreStrategy'); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('catalog_search_screen')), findsOneWidget); + final query = repository.calls.first.query; + expect(query.text, 'catan'); + expect(query.filters.players, 3); + expect(query.filters.maxMinutes, 60); + expect(query.filters.genreIds, {genreStrategy}); + }); + + testWidgets('invalid URL parameters are ignored, the page still opens', ( + tester, + ) async { + useWindow(tester, 420); + final repository = FakeCatalogRepository(all: [game(1)]); + final container = await pumpShuffleApp( + tester, + overrides: catalogOverrides(repository), + ); + + container + .read(appRouterProvider) + .go('/catalog?players=99&genre=xyz&maxMinutes=abc'); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('catalog_search_screen')), findsOneWidget); + expect(repository.calls.first.query.isEmpty, isTrue); + }); + + testWidgets('start page entry opens the search and back returns', ( + tester, + ) async { + useWindow(tester, 420); + final repository = FakeCatalogRepository(all: [game(1)]); + await pumpShuffleApp(tester, overrides: catalogOverrides(repository)); + + await tester.tap(find.byKey(const Key('home_catalog_search_button'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('catalog_search_screen')), findsOneWidget); + expect(find.text('Spiel 1'), findsOneWidget); + + await tester.tap(find.byType(BackButton)); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('home_screen')), findsOneWidget); + }); + }); + + group('URL sync (web)', () { + late List platformCalls; + + setUp(() => platformCalls = []); + + Future pumpRouted( + WidgetTester tester, + FakeCatalogRepository repository, { + required bool isWeb, + }) async { + useWindow(tester, 420); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.navigation, + (call) async { + platformCalls.add(call); + return null; + }, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.navigation, + null, + ), + ); + final router = buildTestRouter( + routes: catalogRoutes, + initialLocation: '/catalog?q=alt', + ); + addTearDown(router.dispose); + await pumpApp( + tester, + router: router, + overrides: [ + ...catalogOverrides(repository), + urlReplacerProvider.overrideWithValue(UrlReplacer(isWeb: isWeb)), + ], + settle: false, + ); + await tester.pumpAndSettle(); + platformCalls.clear(); + return router; + } + + testWidgets('URL is replaced after a search without rebuilding the page', ( + tester, + ) async { + final repository = FakeCatalogRepository(all: [game(1)]); + final router = await pumpRouted(tester, repository, isWeb: true); + final stateBefore = tester.state(find.byType(CatalogSearchView)); + final callsBefore = repository.calls.length; + + await tester.enterText(searchInput, 'catan'); + await tester.testTextInput.receiveAction(TextInputAction.search); + await tester.pumpAndSettle(); + + // Router and browser address agree on the new search. + expect(currentUri(router).queryParameters['q'], 'catan'); + final updates = platformCalls.where( + (c) => c.method == 'routeInformationUpdated', + ); + expect(updates, isNotEmpty); + final last = updates.last.arguments as Map; + expect(last['uri'].toString(), '/catalog?q=catan'); + expect(last['replace'], isTrue); + // No history entry from the router for this change. + expect( + updates.where((c) => (c.arguments as Map)['replace'] != true), + isEmpty, + ); + // Same page state: nothing rebuilt, field and focus intact. + expect(tester.state(find.byType(CatalogSearchView)), same(stateBefore)); + expect(tester.widget(searchInput).controller!.text, 'catan'); + // Exactly one more search (no re-init after the URL change). + expect(repository.calls.length, callsBefore + 1); + }); + + testWidgets('filters are part of the written URL', (tester) async { + final repository = FakeCatalogRepository(all: [game(1)]); + final router = await pumpRouted(tester, repository, isWeb: true); + await tester.tap(find.byKey(const Key('catalog_filter_button'))); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('catalog_term_$genreFamily'))); + await tester.pumpAndSettle(); + + final params = currentUri(router).queryParameters; + expect(params['q'], 'alt'); + expect(params['genre'], genreFamily); + }); + + testWidgets('outside the web nothing is written', (tester) async { + final repository = FakeCatalogRepository(all: [game(1)]); + final router = await pumpRouted(tester, repository, isWeb: false); + + await tester.enterText(searchInput, 'catan'); + await tester.testTextInput.receiveAction(TextInputAction.search); + await tester.pumpAndSettle(); + + expect( + platformCalls.where((c) => c.method == 'routeInformationUpdated'), + isEmpty, + ); + expect(currentUri(router).queryParameters['q'], 'alt'); + }); + }); +} diff --git a/app/test/features/catalog/presentation/search/catalog_search_view_test.dart b/app/test/features/catalog/presentation/search/catalog_search_view_test.dart index e022363..dbc882e 100644 --- a/app/test/features/catalog/presentation/search/catalog_search_view_test.dart +++ b/app/test/features/catalog/presentation/search/catalog_search_view_test.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:flutter/semantics.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shuffle/core/widgets/game_cover.dart'; import 'package:shuffle/features/catalog/domain/catalog_failure.dart'; From 80b548819b34a294c996001c91baca32beeb2711 Mon Sep 17 00:00:00 2001 From: cschlaefke Date: Wed, 4 Nov 2026 01:18:23 +0100 Subject: [PATCH 4/8] docs(design): list components and catalog search behavior Refs #3 Co-Authored-By: Claude Sonnet 5.5 --- docs/design-system.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docs/design-system.md b/docs/design-system.md index e65bc7d..b9b0239 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -79,4 +79,22 @@ getestet; Tests liegen in `app/test/core/widgets/` und dienen als lauffähige Vo | `AppBanner` | `type` (`AppBannerType.info` / `error`), `message`, `typeLabel` (gesprochenes Präfix, z. B. „Fehler“), optional `actionLabel` + `onAction` (48-dp-TextButton), `actionEnabled` (Standard `true`; `false` lässt die Aktion sichtbar, aber deaktiviert, z. B. während eine Anfrage läuft; Semantics melden sie als nicht aktiviert, der Screenreader-Fokus geht nicht verloren). Live-Region mit „typeLabel: message“. | | `AppFieldError` | Interner Baustein: Fehlerzeile mit Icon als Live-Region, von Textfeld und Checkbox genutzt. | +## Listen-Komponenten (`app/lib/core/widgets/`, #3 KAT-01) + +Für jede Liste in R1 (Zustände Krit. 1–3): Leer- und Fehlerzustand sowie Listenfuß zum Nachladen. Gleiche Regeln wie bei den +Formular-Komponenten (Texte von außen, Tests gegen die Guidelines in Hell und Dunkel, Umbruch statt Abschneiden bei 200 %). + +| Komponente | Zweck und API (Kurzform) | +|---|---| +| `AppStateMessage` | Leer-/Fehlerzustand: `icon` (dekorativ, für Screenreader ausgeblendet), `title`, optional `message`, `actions` (`AppStateAction(label, onPressed, key)`; die erste Aktion ist tonal hervorgehoben, weitere umrandet; alle 48 dp). Titel und Hinweis sind eine Live-Region. | +| `AppListFooter` | Fuß einer seitenweise geladenen Liste: `isLoading` (beschrifteter Fortschrittskreis), `hasMore` (Schaltfläche „Weitere laden“, per Tastatur und Screenreader erreichbar, optional `loadMoreFocusNode`), `errorMessage` + Wiederholen (bereits geladene Einträge bleiben). Vorrang: Fehler vor Laden vor „Weitere laden“. Automatisches Nachladen ist Sache des Aufrufers; die Schaltfläche bleibt der Weg für Tastatur und Screenreader. | + +**Katalogsuche (`features/catalog/presentation/search/`)** – Aufbau und Verhalten, Entscheidungen der Umsetzung (Spec #3 § 5.6): + +- Kompakt (< 600 dp) und mittel (600–1023 dp): Suchfeld, darunter die Schaltfläche „Filter (n)“ mit Bottom Sheet (Änderungen wirken sofort, „Fertig“ schließt); ab 1024 dp feste Seitenleiste (320 dp) links, Trefferliste rechts mit höchstens 720 dp Breite. +- Einfachauswahl (Spielerzahl, Dauer, Alter) als beschriftete Auswahlfelder mit „Beliebig“; Genre und Mechanik als Filter-Chips, nach lokalisiertem Namen sortiert. Fehler beim Laden der Begriffe betrifft nur diese beiden Bereiche („Erneut versuchen“). +- Trefferkachel: `GameCover` klein (Platzhalter), Titel (2 Zeilen), „auch: …“, Jahr · Verlage (zwei, sonst „+n“), Kurzangaben; ein zusammengefasstes Semantics-Label. Ohne `onTap` ist die Kachel kein Button, aber per Code fokussierbar (Fokusring 3 dp, kein Tab-Stopp). +- Trefferzahl über der Liste; Ansage der Trefferzahl 600 ms nach Abschluss der letzten Suche (`SemanticsService.sendAnnouncement`), nie während des Tippens. +- Nach „Weitere laden“ (bzw. „Erneut versuchen“ im Listenfuß) springt der Fokus auf die erste neu geladene Kachel. Automatisches Nachladen, wenn weniger als etwa fünf Kacheln unterhalb des sichtbaren Bereichs liegen; nach einem Fehler nur noch über die Schaltfläche (keine Wiederholungsschleife). + Auth-Screens: Layout-Skizzen und Textvorschläge siehe `docs/design/auth-screens.md`. From 2d18a49fc260049f30604fca0bd06f06cab9a8e8 Mon Sep 17 00:00:00 2001 From: cschlaefke Date: Wed, 4 Nov 2026 01:25:23 +0100 Subject: [PATCH 5/8] fix(catalog): keyboard reach of the result list, tab order, stable select focus Tiles are tab stops again, tab order is search field, filters, list (also with the side bar), selects keep focus after a choice. Failure kind is logged with stable names. Refs #3 Co-Authored-By: Claude Sonnet 5.5 --- .../data/supabase_catalog_repository.dart | 13 +- .../search/catalog_search_view.dart | 65 +++++---- .../search/widgets/catalog_filter_panel.dart | 35 ++--- .../search/widgets/catalog_game_tile.dart | 9 +- .../search/catalog_search_a11y_test.dart | 2 +- .../search/catalog_search_behavior_test.dart | 127 ++++++++++++++++-- .../search/catalog_search_screen_test.dart | 4 +- .../search/catalog_search_test_support.dart | 48 +++++++ .../search/catalog_search_view_test.dart | 44 +----- 9 files changed, 240 insertions(+), 107 deletions(-) create mode 100644 app/test/features/catalog/presentation/search/catalog_search_test_support.dart diff --git a/app/lib/features/catalog/data/supabase_catalog_repository.dart b/app/lib/features/catalog/data/supabase_catalog_repository.dart index 2edee36..ab153c4 100644 --- a/app/lib/features/catalog/data/supabase_catalog_repository.dart +++ b/app/lib/features/catalog/data/supabase_catalog_repository.dart @@ -101,13 +101,16 @@ class SupabaseCatalogRepository implements CatalogRepository { } } - /// Maps and logs an error. Logs the failure kind and the error type only. + /// Maps and logs an error. Logs the failure kind only. CatalogFailure _fail(String operation, Object error) { final failure = mapCatalogError(error); - _log.warning( - 'Catalog $operation failed: ${failure.runtimeType} ' - '(${error.runtimeType})', - ); + // Stable names (runtime type names are minified on the web). + final kind = switch (failure) { + CatalogNetworkFailure() => 'network', + CatalogInvalidInputFailure() => 'invalidInput', + CatalogUnexpectedFailure() => 'unexpected', + }; + _log.warning('Catalog $operation failed: $kind'); return failure; } } 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 c4a112e..62c1d76 100644 --- a/app/lib/features/catalog/presentation/search/catalog_search_view.dart +++ b/app/lib/features/catalog/presentation/search/catalog_search_view.dart @@ -263,37 +263,52 @@ class _CatalogSearchViewState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Padding( - padding: EdgeInsets.fromLTRB( - padding.left, - padding.top, - padding.right, - AppSpacing.sm, + FocusTraversalOrder( + order: const NumericFocusOrder(1), + child: Padding( + padding: EdgeInsets.fromLTRB( + padding.left, + padding.top, + padding.right, + AppSpacing.sm, + ), + child: _buildHeader(state, showFilterButton: !expanded), + ), + ), + Expanded( + child: FocusTraversalOrder( + order: const NumericFocusOrder(3), + child: _buildResults(state, padding), ), - child: _buildHeader(state, showFilterButton: !expanded), ), - Expanded(child: _buildResults(state, padding)), ], ), ), ); - if (!expanded) { - return main; - } - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: _sidebarWidth, - child: SingleChildScrollView( - key: const Key('catalog_filter_sidebar'), - padding: EdgeInsets.all(padding.left), - child: _buildSidebar(state), - ), - ), - const VerticalDivider(width: 1), - Expanded(child: main), - ], + // Tab order (Spec #3 § 6): search field, filters, list. With the side + // bar left of the field the visual order alone would be wrong. + return FocusTraversalGroup( + policy: OrderedTraversalPolicy(), + child: expanded + ? Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FocusTraversalOrder( + order: const NumericFocusOrder(2), + child: SizedBox( + width: _sidebarWidth, + child: SingleChildScrollView( + key: const Key('catalog_filter_sidebar'), + padding: EdgeInsets.all(padding.left), + child: _buildSidebar(state), + ), + ), + ), + const VerticalDivider(width: 1), + Expanded(child: main), + ], + ) + : main, ); }, ); diff --git a/app/lib/features/catalog/presentation/search/widgets/catalog_filter_panel.dart b/app/lib/features/catalog/presentation/search/widgets/catalog_filter_panel.dart index 411a078..5e02f3b 100644 --- a/app/lib/features/catalog/presentation/search/widgets/catalog_filter_panel.dart +++ b/app/lib/features/catalog/presentation/search/widgets/catalog_filter_panel.dart @@ -118,24 +118,29 @@ class _SingleChoice extends StatelessWidget { @override Widget build(BuildContext context) { + // A controlled drop-down (no form field that keeps its own value), so a + // selection does not rebuild the field and keyboard focus stays on it. return KeyedSubtree( key: fieldKey, - child: DropdownButtonFormField( - key: ValueKey(value), - // The field keeps its own value; a new key follows outside changes - // (reset, URL). - initialValue: value, - isExpanded: true, + child: InputDecorator( + // "Any" is a value too, so the label always floats. + isEmpty: false, decoration: InputDecoration(labelText: label), - items: [ - DropdownMenuItem(child: Text(anyLabel)), - for (final option in options) - DropdownMenuItem( - value: option, - child: Text(optionLabel(option)), - ), - ], - onChanged: onChanged, + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: value, + isExpanded: true, + items: [ + DropdownMenuItem(child: Text(anyLabel)), + for (final option in options) + DropdownMenuItem( + value: option, + child: Text(optionLabel(option)), + ), + ], + onChanged: onChanged, + ), + ), ), ); } diff --git a/app/lib/features/catalog/presentation/search/widgets/catalog_game_tile.dart b/app/lib/features/catalog/presentation/search/widgets/catalog_game_tile.dart index 4cbda29..c709982 100644 --- a/app/lib/features/catalog/presentation/search/widgets/catalog_game_tile.dart +++ b/app/lib/features/catalog/presentation/search/widgets/catalog_game_tile.dart @@ -8,9 +8,10 @@ import 'package:shuffle/features/catalog/domain/catalog_game_summary.dart'; /// /// Shows a [GameCover] placeholder (no image URL in #3), title, year, /// publishers and short facts; missing facts are left out. [onTap] is `null` -/// in #3: the tile is then not a button (no dead link) but still focusable -/// programmatically ([focusNode]) so keyboard focus can move to the first -/// newly loaded tile. +/// in #3: the tile is then not a button (no dead link) but stays a keyboard +/// stop with a visible focus ring, so keyboard users can walk the list (which +/// scrolls it and loads more) and focus can move to the first newly loaded +/// tile ([focusNode]). class CatalogGameTile extends StatefulWidget { const CatalogGameTile({ required this.game, @@ -147,8 +148,6 @@ class _CatalogGameTileState extends State { final onTap = widget.onTap; return Focus( focusNode: widget.focusNode, - // Not a tab stop while the tile does nothing, but focusable by code. - skipTraversal: onTap == null, onFocusChange: (focused) => setState(() => _focused = focused), child: Semantics( container: true, diff --git a/app/test/features/catalog/presentation/search/catalog_search_a11y_test.dart b/app/test/features/catalog/presentation/search/catalog_search_a11y_test.dart index f8dd4f8..a60a35c 100644 --- a/app/test/features/catalog/presentation/search/catalog_search_a11y_test.dart +++ b/app/test/features/catalog/presentation/search/catalog_search_a11y_test.dart @@ -3,7 +3,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:shuffle/features/catalog/domain/catalog_search_query.dart'; import '../../catalog_test_support.dart'; -import 'catalog_search_view_test.dart'; +import 'catalog_search_test_support.dart'; void main() { group('announcements', () { diff --git a/app/test/features/catalog/presentation/search/catalog_search_behavior_test.dart b/app/test/features/catalog/presentation/search/catalog_search_behavior_test.dart index 4fdbcb8..05663f2 100644 --- a/app/test/features/catalog/presentation/search/catalog_search_behavior_test.dart +++ b/app/test/features/catalog/presentation/search/catalog_search_behavior_test.dart @@ -5,7 +5,7 @@ import 'package:shuffle/features/catalog/domain/catalog_failure.dart'; import 'package:shuffle/features/catalog/domain/catalog_search_query.dart'; import '../../catalog_test_support.dart'; -import 'catalog_search_view_test.dart'; +import 'catalog_search_test_support.dart'; Future openFilterSheet(WidgetTester tester) async { await tester.tap(find.byKey(const Key('catalog_filter_button'))); @@ -184,25 +184,128 @@ void main() { }); group('keyboard', () { - testWidgets('QUE-06 Kriterium 4: Tab leads from the field to the filters', ( + Future tab(WidgetTester tester) async { + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pump(); + } + + bool focusIsIn(Finder finder) { + final focused = FocusManager.instance.primaryFocus?.context; + return focused != null && + find + .descendant( + of: finder, + matching: find.byElementPredicate((e) => e == focused), + ) + .evaluate() + .isNotEmpty; + } + + /// The tile's `Focus` sits above its keyed card. + bool focusOnTile(String id) { + final focused = FocusManager.instance.primaryFocus?.context; + return focused != null && + find + .ancestor( + of: find.byKey(Key('catalog_tile_$id')), + matching: find.byElementPredicate((e) => e == focused), + ) + .evaluate() + .isNotEmpty; + } + + double scrollOffset(WidgetTester tester) => tester + .state( + find.descendant( + of: find.byKey(const Key('catalog_result_list')), + matching: find.byType(Scrollable), + ), + ) + .position + .pixels; + + testWidgets( + 'QUE-06 Kriterium 4: Tab walks field, filters, list (compact)', + (tester) async { + final repository = FakeCatalogRepository(all: List.generate(75, game)); + await pumpView(tester, repository); + + await tester.tap(searchInput); + await tester.pump(); + await tab(tester); + expect( + focusIsIn(find.byKey(const Key('catalog_filter_button'))), + isTrue, + ); + + await tab(tester); + expect(focusOnTile('game-0'), isTrue); + + // Walking on scrolls the list (and so loads more). + for (var i = 0; i < 12; i++) { + await tab(tester); + } + expect(scrollOffset(tester), greaterThan(0)); + expect(focusOnTile('game-12'), isTrue); + }, + ); + + testWidgets( + 'QUE-06 Kriterium 4: Tab order is field, filters, list (side bar)', + (tester) async { + final repository = FakeCatalogRepository(all: List.generate(75, game)); + await pumpView(tester, repository, width: 1200); + + await tester.tap(searchInput); + await tester.pump(); + await tab(tester); + expect( + focusIsIn(find.byKey(const Key('catalog_filter_sidebar'))), + isTrue, + reason: 'the filters come right after the search field', + ); + + // Through the filters (3 selects, 2 chip groups, reset) to the list. + var steps = 0; + while (!focusOnTile('game-0') && steps++ < 30) { + await tab(tester); + } + expect(focusOnTile('game-0'), isTrue); + expect(steps, lessThan(30)); + }, + ); + + testWidgets('a selection keeps keyboard focus on the select', ( tester, ) async { final repository = FakeCatalogRepository(all: [game(1)]); await pumpView(tester, repository); + await openFilterSheet(tester); - await tester.tap(searchInput); - await tester.pump(); - await tester.sendKeyEvent(LogicalKeyboardKey.tab); - await tester.pump(); - final focused = FocusManager.instance.primaryFocus!.context; + await tester.tap(find.byKey(const Key('catalog_filter_players'))); + await tester.pumpAndSettle(); + await tester.tap(find.text('4 Spieler').last); + await tester.pumpAndSettle(); + + expect(repository.calls.last.query.filters.players, 4); expect( - find.descendant( - of: find.byKey(const Key('catalog_filter_button')), - matching: find.byElementPredicate((e) => e == focused), - ), - findsOneWidget, + focusIsIn(find.byKey(const Key('catalog_filter_players'))), + isTrue, ); }); + + testWidgets('the sheet stays open while filters change the URL state', ( + tester, + ) async { + final repository = FakeCatalogRepository(all: [game(1)]); + await pumpView(tester, repository); + await openFilterSheet(tester); + + await tester.tap(find.byKey(const Key('catalog_term_$genreFamily'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('catalog_filter_sheet')), findsOneWidget); + }); }); group('layout', () { 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 f9398f0..331c5f1 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 @@ -13,7 +13,7 @@ import 'package:shuffle/features/catalog/presentation/search/catalog_search_view import '../../../../helpers/pump_app.dart'; import '../../../../helpers/router_helpers.dart'; import '../../catalog_test_support.dart'; -import 'catalog_search_view_test.dart'; +import 'catalog_search_test_support.dart'; void main() { group('route /catalog', () { @@ -187,6 +187,8 @@ void main() { final params = currentUri(router).queryParameters; expect(params['q'], 'alt'); expect(params['genre'], genreFamily); + // Writing the URL does not close the open filter sheet. + expect(find.byKey(const Key('catalog_filter_sheet')), findsOneWidget); }); testWidgets('outside the web nothing is written', (tester) async { diff --git a/app/test/features/catalog/presentation/search/catalog_search_test_support.dart b/app/test/features/catalog/presentation/search/catalog_search_test_support.dart new file mode 100644 index 0000000..9e70a1c --- /dev/null +++ b/app/test/features/catalog/presentation/search/catalog_search_test_support.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shuffle/features/catalog/domain/catalog_search_query.dart'; +import 'package:shuffle/features/catalog/presentation/search/catalog_search_view.dart'; + +import '../../../../helpers/pump_app.dart'; +import '../../catalog_test_support.dart'; + +/// Sets a logical window size for the test (device pixel ratio 1). +void useWindow(WidgetTester tester, double width, [double height = 900]) { + tester.view.physicalSize = Size(width, height); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); +} + +Future pumpView( + WidgetTester tester, + FakeCatalogRepository repository, { + CatalogSearchQuery initialQuery = const CatalogSearchQuery(), + ValueChanged? onReportGame, + Locale locale = const Locale('de'), + double? textScale, + double width = 420, + bool settle = true, +}) async { + useWindow(tester, width); + await pumpApp( + tester, + locale: locale, + textScale: textScale, + overrides: catalogOverrides(repository), + settle: false, + home: Scaffold( + body: CatalogSearchView( + initialQuery: initialQuery, + onReportGame: onReportGame, + ), + ), + ); + if (settle) { + await tester.pumpAndSettle(); + } +} + +Finder get searchInput => find.descendant( + of: find.byKey(const Key('catalog_search_field')), + matching: find.byType(TextField), +); diff --git a/app/test/features/catalog/presentation/search/catalog_search_view_test.dart b/app/test/features/catalog/presentation/search/catalog_search_view_test.dart index dbc882e..99cf86d 100644 --- a/app/test/features/catalog/presentation/search/catalog_search_view_test.dart +++ b/app/test/features/catalog/presentation/search/catalog_search_view_test.dart @@ -4,51 +4,9 @@ import 'package:shuffle/core/widgets/game_cover.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/presentation/search/catalog_search_controller.dart'; -import 'package:shuffle/features/catalog/presentation/search/catalog_search_view.dart'; -import '../../../../helpers/pump_app.dart'; import '../../catalog_test_support.dart'; - -/// Sets a logical window size for the test (device pixel ratio 1). -void useWindow(WidgetTester tester, double width, [double height = 900]) { - tester.view.physicalSize = Size(width, height); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.reset); -} - -Future pumpView( - WidgetTester tester, - FakeCatalogRepository repository, { - CatalogSearchQuery initialQuery = const CatalogSearchQuery(), - ValueChanged? onReportGame, - Locale locale = const Locale('de'), - double? textScale, - double width = 420, - bool settle = true, -}) async { - useWindow(tester, width); - await pumpApp( - tester, - locale: locale, - textScale: textScale, - overrides: catalogOverrides(repository), - settle: false, - home: Scaffold( - body: CatalogSearchView( - initialQuery: initialQuery, - onReportGame: onReportGame, - ), - ), - ); - if (settle) { - await tester.pumpAndSettle(); - } -} - -Finder get searchInput => find.descendant( - of: find.byKey(const Key('catalog_search_field')), - matching: find.byType(TextField), -); +import 'catalog_search_test_support.dart'; void main() { group('list and tiles', () { From ae099eb3d7487f2a6f9a10d032adc19af02e1feb Mon Sep 17 00:00:00 2001 From: cschlaefke Date: Wed, 4 Nov 2026 09:59:54 +0100 Subject: [PATCH 6/8] feat(catalog): labeled start page button, prune unknown term IDs from the URL Refs #3 Co-Authored-By: Claude Sonnet 5.5 --- .../search/catalog_search_controller.dart | 19 ++++ .../search/catalog_search_controller.g.dart | 2 +- .../search/catalog_search_view.dart | 44 ++++++++++ .../widgets/catalog_search_entry_button.dart | 7 +- .../home/presentation/home_screen.dart | 20 +++-- .../search/catalog_search_screen_test.dart | 87 ++++++++++++++++++- 6 files changed, 166 insertions(+), 13 deletions(-) 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); From 25212929ce04845421dbca08b423f307d17c7936 Mon Sep 17 00:00:00 2001 From: cschlaefke Date: Wed, 4 Nov 2026 10:17:54 +0100 Subject: [PATCH 7/8] feat(catalog): review fixes for catalog search Word limit and no-retry state for rejected input, nested /catalog route for system back, logger filter for supabase libs, more mutation-proof tests, doc fix, umlaut-aware term sorting, thousands separators. Refs #3 Co-Authored-By: Claude Sonnet 5.5 --- app/lib/core/logging/app_logger.dart | 10 +++++ app/lib/core/routing/app_router.dart | 2 +- app/lib/core/routing/app_router.g.dart | 2 +- app/lib/core/routing/app_routes.dart | 3 ++ app/lib/core/widgets/app_list_footer.dart | 6 --- .../catalog/domain/catalog_search_query.dart | 26 ++++++++++--- .../catalog/presentation/catalog_routes.dart | 7 +++- .../search/catalog_search_view.dart | 18 ++++++--- .../search/widgets/catalog_filter_panel.dart | 37 ++++++++++++++++-- app/lib/l10n/app_de.arb | 3 +- app/lib/l10n/app_en.arb | 8 ++-- app/lib/l10n/app_localizations.dart | 6 +++ app/lib/l10n/app_localizations_de.dart | 16 +++++++- app/lib/l10n/app_localizations_en.dart | 16 +++++++- app/test/core/logging/app_logger_test.dart | 15 ++++++++ .../core/widgets/app_state_message_test.dart | 26 ------------- .../catalog/catalog_test_support.dart | 1 + .../supabase_catalog_repository_test.dart | 38 ++++++++++++++++++- .../catalog/domain/catalog_domain_test.dart | 29 ++++++++++++++ .../search/catalog_search_behavior_test.dart | 29 ++++++++++++++ .../catalog_search_controller_test.dart | 25 ++++++++++++ .../search/catalog_search_screen_test.dart | 28 +++++++++++++- .../search/catalog_search_view_test.dart | 17 +++++++++ docs/design-system.md | 4 +- 24 files changed, 310 insertions(+), 62 deletions(-) diff --git a/app/lib/core/logging/app_logger.dart b/app/lib/core/logging/app_logger.dart index a10f4d6..8a8a0f2 100644 --- a/app/lib/core/logging/app_logger.dart +++ b/app/lib/core/logging/app_logger.dart @@ -48,7 +48,17 @@ class AppLogger { _subscription = null; } + /// Whether a record is written. Records of the Supabase libraries below + /// INFO are dropped: postgrest logs `'$error from request'` at FINE, and the + /// error text can echo request parameters (search terms, QUE-04). + @visibleForTesting + static bool shouldWrite(LogRecord record) => + !(record.loggerName.startsWith('supabase') && record.level < Level.INFO); + static void _write(LogRecord record) { + if (!shouldWrite(record)) { + return; + } developer.log( record.message, time: record.time, diff --git a/app/lib/core/routing/app_router.dart b/app/lib/core/routing/app_router.dart index 63baec9..df069ff 100644 --- a/app/lib/core/routing/app_router.dart +++ b/app/lib/core/routing/app_router.dart @@ -24,9 +24,9 @@ GoRouter appRouter(Ref ref) { GoRoute( path: AppRoutes.home, builder: (context, state) => const HomeScreen(), + routes: catalogRoutes, ), ...authRoutes, - ...catalogRoutes, ], ); ref.onDispose(router.dispose); diff --git a/app/lib/core/routing/app_router.g.dart b/app/lib/core/routing/app_router.g.dart index 13b150b..8998754 100644 --- a/app/lib/core/routing/app_router.g.dart +++ b/app/lib/core/routing/app_router.g.dart @@ -48,4 +48,4 @@ final class AppRouterProvider } } -String _$appRouterHash() => r'e5b9cf23cd96b28b8941552d226d592a1e06efc1'; +String _$appRouterHash() => r'cb20752ab64cf1ac56d3bfce5401e187246f5ec7'; diff --git a/app/lib/core/routing/app_routes.dart b/app/lib/core/routing/app_routes.dart index 3ce97d5..1e22bcf 100644 --- a/app/lib/core/routing/app_routes.dart +++ b/app/lib/core/routing/app_routes.dart @@ -5,6 +5,9 @@ abstract final class AppRoutes { /// Katalogsuche, öffentlich (Spec #3 § 5.5). static const catalog = '/catalog'; + /// Segment of [catalog] below [home] (nested route). + static const catalogSegment = 'catalog'; + // Auth (Spec #1 § 5.3) static const login = '/login'; static const register = '/register'; diff --git a/app/lib/core/widgets/app_list_footer.dart b/app/lib/core/widgets/app_list_footer.dart index 38bd22f..93b4381 100644 --- a/app/lib/core/widgets/app_list_footer.dart +++ b/app/lib/core/widgets/app_list_footer.dart @@ -18,7 +18,6 @@ class AppListFooter extends StatelessWidget { required this.onRetry, super.key, this.errorMessage, - this.loadMoreFocusNode, }); final bool isLoading; @@ -32,10 +31,6 @@ class AppListFooter extends StatelessWidget { final VoidCallback onLoadMore; final VoidCallback onRetry; - /// Focus node of the "load more" button (callers restore focus elsewhere - /// after loading). - final FocusNode? loadMoreFocusNode; - @override Widget build(BuildContext context) { final message = errorMessage; @@ -79,7 +74,6 @@ class AppListFooter extends StatelessWidget { } else if (hasMore) { content = OutlinedButton( key: const Key('list_footer_load_more'), - focusNode: loadMoreFocusNode, onPressed: onLoadMore, child: Text(loadMoreLabel), ); diff --git a/app/lib/features/catalog/domain/catalog_search_query.dart b/app/lib/features/catalog/domain/catalog_search_query.dart index 70d3035..13e0e9d 100644 --- a/app/lib/features/catalog/domain/catalog_search_query.dart +++ b/app/lib/features/catalog/domain/catalog_search_query.dart @@ -19,6 +19,9 @@ abstract final class CatalogSearchLimits { /// "Suitable from X years" steps. static const List ageSteps = [6, 8, 10, 12, 14, 16, 18]; + /// The backend rejects search texts with more words. + static const int maxWords = 10; + /// Most genres or mechanics that can be selected at once. static const int maxTerms = 20; @@ -77,12 +80,25 @@ abstract class CatalogSearchQuery with _$CatalogSearchQuery { const CatalogSearchQuery._(); - /// Trims [raw] and cuts it to the maximum length. + static final RegExp _word = RegExp(r'[\p{L}\p{N}]+', unicode: true); + + /// Trims [raw] and cuts it to the maximum length and to at most + /// [CatalogSearchLimits.maxWords] words. The backend counts words after its + /// normalization (punctuation separates words, one-letter words count) and + /// rejects more; this is the client-side approximation, the rest is cut off + /// silently. static String cleanText(String raw) { - final trimmed = raw.trim(); - return trimmed.length > CatalogSearchLimits.maxTextLength - ? trimmed.substring(0, CatalogSearchLimits.maxTextLength).trim() - : trimmed; + var text = raw.trim(); + if (text.length > CatalogSearchLimits.maxTextLength) { + text = text.substring(0, CatalogSearchLimits.maxTextLength).trim(); + } + var count = 0; + for (final match in _word.allMatches(text)) { + if (++count == CatalogSearchLimits.maxWords) { + return text.substring(0, match.end); + } + } + return text; } /// Whether the text is long enough to count as a search term. diff --git a/app/lib/features/catalog/presentation/catalog_routes.dart b/app/lib/features/catalog/presentation/catalog_routes.dart index 00642e3..c8c9b7f 100644 --- a/app/lib/features/catalog/presentation/catalog_routes.dart +++ b/app/lib/features/catalog/presentation/catalog_routes.dart @@ -4,9 +4,14 @@ import 'package:shuffle/features/catalog/presentation/catalog_query_params.dart' import 'package:shuffle/features/catalog/presentation/search/catalog_search_screen.dart'; /// Routes of the catalog (Spec #3 § 5.5). `/catalog` is public. +/// +/// These are sub-routes of the start page (path `catalog` below `/`), so the +/// URL stays `/catalog`, but a deep link builds the stack [home, catalog] and +/// the system back button (Android) returns to the start page instead of +/// closing the app. final List catalogRoutes = [ GoRoute( - path: AppRoutes.catalog, + path: AppRoutes.catalogSegment, builder: (context, state) => CatalogSearchScreen( initialQuery: parseCatalogQuery(state.uri.queryParameters), ), 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 b48fa86..8ebaeef 100644 --- a/app/lib/features/catalog/presentation/search/catalog_search_view.dart +++ b/app/lib/features/catalog/presentation/search/catalog_search_view.dart @@ -424,19 +424,25 @@ class _CatalogSearchViewState extends ConsumerState { ), ); case CatalogSearchStatus.error: - final offline = state.failure is CatalogNetworkFailure; + final failure = state.failure; + final offline = failure is CatalogNetworkFailure; + // Rejected input does not get better by repeating it: no retry. + final invalid = failure is CatalogInvalidInputFailure; return AppStateMessage( key: const Key('catalog_error'), icon: offline ? Icons.wifi_off : Icons.error_outline, title: offline ? l10n.catalogSearchErrorOffline + : invalid + ? l10n.catalogSearchErrorInvalid : l10n.catalogSearchErrorGeneric, actions: [ - AppStateAction( - key: const Key('catalog_error_retry'), - label: l10n.catalogSearchRetry, - onPressed: () => unawaited(_controller.retry()), - ), + if (!invalid) + AppStateAction( + key: const Key('catalog_error_retry'), + label: l10n.catalogSearchRetry, + onPressed: () => unawaited(_controller.retry()), + ), ], ); case CatalogSearchStatus.empty: diff --git a/app/lib/features/catalog/presentation/search/widgets/catalog_filter_panel.dart b/app/lib/features/catalog/presentation/search/widgets/catalog_filter_panel.dart index 5e02f3b..db66c1b 100644 --- a/app/lib/features/catalog/presentation/search/widgets/catalog_filter_panel.dart +++ b/app/lib/features/catalog/presentation/search/widgets/catalog_filter_panel.dart @@ -174,10 +174,9 @@ class _TermChoice extends StatelessWidget { data: (list) { final sorted = [...list] ..sort( - (a, b) => a - .localizedName(language) - .toLowerCase() - .compareTo(b.localizedName(language).toLowerCase()), + (a, b) => + _sortKey(a.localizedName(language)) + .compareTo(_sortKey(b.localizedName(language))), ); final full = selected.length >= CatalogSearchLimits.maxTerms; return Wrap( @@ -239,3 +238,33 @@ class _TermChoice extends StatelessWidget { ); } } + +/// Sort key that puts umlauts and accents next to their base letters. +String _sortKey(String name) { + const map = { + 'ä': 'a', + 'ö': 'o', + 'ü': 'u', + 'ß': 'ss', + 'é': 'e', + 'è': 'e', + 'ê': 'e', + 'á': 'a', + 'à': 'a', + 'â': 'a', + 'í': 'i', + 'ó': 'o', + 'ô': 'o', + 'ú': 'u', + 'ç': 'c', + 'ñ': 'n', + 'æ': 'ae', + 'œ': 'oe', + }; + final buffer = StringBuffer(); + for (final rune in name.toLowerCase().runes) { + final char = String.fromCharCode(rune); + buffer.write(map[char] ?? char); + } + return buffer.toString(); +} diff --git a/app/lib/l10n/app_de.arb b/app/lib/l10n/app_de.arb index 1468e3f..025862b 100644 --- a/app/lib/l10n/app_de.arb +++ b/app/lib/l10n/app_de.arb @@ -115,5 +115,6 @@ "catalogSearchEmptyCatalog": "Der Katalog ist noch leer.", "catalogSearchReportGame": "Fehlendes Spiel melden", "catalogSearchErrorOffline": "Keine Verbindung. Für die Suche brauchst du Internet.", - "catalogSearchErrorGeneric": "Die Suche ist gerade nicht möglich." + "catalogSearchErrorGeneric": "Die Suche ist gerade nicht möglich.", + "catalogSearchErrorInvalid": "Diese Suche wird nicht unterstützt. Ändere den Suchbegriff oder die Filter." } diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index abc4c88..b51f889 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -169,9 +169,9 @@ "catalogSearchRetry": "Try again", "@catalogSearchRetry": {"description": "Retry button"}, "catalogSearchResultCount": "{count, plural, =0{No games} =1{1 game} other{{count} games}}", - "@catalogSearchResultCount": {"description": "Number of hits above the list", "placeholders": {"count": {"type": "int"}}}, + "@catalogSearchResultCount": {"description": "Number of hits above the list", "placeholders": {"count": {"type": "int", "format": "decimalPattern"}}}, "catalogSearchAnnouncement": "{count, plural, =0{No games found} =1{1 game found} other{{count} games found}}", - "@catalogSearchAnnouncement": {"description": "Screen reader announcement after a finished search", "placeholders": {"count": {"type": "int"}}}, + "@catalogSearchAnnouncement": {"description": "Screen reader announcement after a finished search", "placeholders": {"count": {"type": "int", "format": "decimalPattern"}}}, "catalogSearchAlsoKnownAs": "also: {title}", "@catalogSearchAlsoKnownAs": {"description": "Alternative title that matched", "placeholders": {"title": {"type": "String"}}}, "catalogSearchPlayersCount": "{count, plural, =1{1 player} other{{count} players}}", @@ -205,5 +205,7 @@ "catalogSearchErrorOffline": "No connection. You need internet for the search.", "@catalogSearchErrorOffline": {"description": "Error state: offline"}, "catalogSearchErrorGeneric": "Search is not available right now.", - "@catalogSearchErrorGeneric": {"description": "Error state: other errors"} + "@catalogSearchErrorGeneric": {"description": "Error state: other errors"}, + "catalogSearchErrorInvalid": "This search is not supported. Change the search text or the filters.", + "@catalogSearchErrorInvalid": {"description": "Error state: the backend rejected the input (no retry offered)"} } diff --git a/app/lib/l10n/app_localizations.dart b/app/lib/l10n/app_localizations.dart index 717eb9d..5030497 100644 --- a/app/lib/l10n/app_localizations.dart +++ b/app/lib/l10n/app_localizations.dart @@ -793,6 +793,12 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Search is not available right now.'** String get catalogSearchErrorGeneric; + + /// Error state: the backend rejected the input (no retry offered) + /// + /// In en, this message translates to: + /// **'This search is not supported. Change the search text or the filters.'** + String get catalogSearchErrorInvalid; } class _AppLocalizationsDelegate diff --git a/app/lib/l10n/app_localizations_de.dart b/app/lib/l10n/app_localizations_de.dart index e5d050f..d830aa4 100644 --- a/app/lib/l10n/app_localizations_de.dart +++ b/app/lib/l10n/app_localizations_de.dart @@ -359,10 +359,14 @@ class AppLocalizationsDe extends AppLocalizations { @override String catalogSearchResultCount(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count Spiele', + other: '$countString Spiele', one: '1 Spiel', zero: 'Keine Spiele', ); @@ -371,10 +375,14 @@ class AppLocalizationsDe extends AppLocalizations { @override String catalogSearchAnnouncement(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count Spiele gefunden', + other: '$countString Spiele gefunden', one: '1 Spiel gefunden', zero: 'Keine Spiele gefunden', ); @@ -457,4 +465,8 @@ class AppLocalizationsDe extends AppLocalizations { @override String get catalogSearchErrorGeneric => 'Die Suche ist gerade nicht möglich.'; + + @override + String get catalogSearchErrorInvalid => + 'Diese Suche wird nicht unterstützt. Ändere den Suchbegriff oder die Filter.'; } diff --git a/app/lib/l10n/app_localizations_en.dart b/app/lib/l10n/app_localizations_en.dart index a6497cb..6d68ced 100644 --- a/app/lib/l10n/app_localizations_en.dart +++ b/app/lib/l10n/app_localizations_en.dart @@ -355,10 +355,14 @@ class AppLocalizationsEn extends AppLocalizations { @override String catalogSearchResultCount(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count games', + other: '$countString games', one: '1 game', zero: 'No games', ); @@ -367,10 +371,14 @@ class AppLocalizationsEn extends AppLocalizations { @override String catalogSearchAnnouncement(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count games found', + other: '$countString games found', one: '1 game found', zero: 'No games found', ); @@ -451,4 +459,8 @@ class AppLocalizationsEn extends AppLocalizations { @override String get catalogSearchErrorGeneric => 'Search is not available right now.'; + + @override + String get catalogSearchErrorInvalid => + 'This search is not supported. Change the search text or the filters.'; } diff --git a/app/test/core/logging/app_logger_test.dart b/app/test/core/logging/app_logger_test.dart index baac91c..a64a5ef 100644 --- a/app/test/core/logging/app_logger_test.dart +++ b/app/test/core/logging/app_logger_test.dart @@ -29,4 +29,19 @@ void main() { expect(records.last.level, Level.SEVERE); expect(records.last.loggerName, 'Test'); }); + + test('QUE-04: Supabase library records below INFO are not written', () { + expect( + AppLogger.shouldWrite(LogRecord(Level.FINE, 'x', 'supabase.postgrest')), + isFalse, + ); + expect( + AppLogger.shouldWrite(LogRecord(Level.WARNING, 'x', 'supabase.auth')), + isTrue, + ); + expect( + AppLogger.shouldWrite(LogRecord(Level.FINE, 'x', 'CatalogRepository')), + isTrue, + ); + }); } diff --git a/app/test/core/widgets/app_state_message_test.dart b/app/test/core/widgets/app_state_message_test.dart index ad570f1..b851488 100644 --- a/app/test/core/widgets/app_state_message_test.dart +++ b/app/test/core/widgets/app_state_message_test.dart @@ -160,31 +160,5 @@ void main() { expect(find.byType(OutlinedButton), findsNothing); expect(find.byKey(const Key('list_footer_loading')), findsNothing); }); - - testWidgets('the load-more button takes a focus node', (tester) async { - final node = FocusNode(); - addTearDown(node.dispose); - await pumpApp( - tester, - home: Scaffold( - body: AppListFooter( - isLoading: false, - hasMore: true, - loadingLabel: 'Lädt', - loadMoreLabel: 'Weitere laden', - retryLabel: 'Erneut versuchen', - onLoadMore: () {}, - onRetry: () {}, - loadMoreFocusNode: node, - ), - ), - settle: false, - ); - - node.requestFocus(); - await tester.pump(); - - expect(node.hasPrimaryFocus, isTrue); - }); }); } diff --git a/app/test/features/catalog/catalog_test_support.dart b/app/test/features/catalog/catalog_test_support.dart index 2922532..3bf0e62 100644 --- a/app/test/features/catalog/catalog_test_support.dart +++ b/app/test/features/catalog/catalog_test_support.dart @@ -16,6 +16,7 @@ import '../../helpers/fakes.dart'; const genreStrategy = '11111111-1111-4111-8111-111111111111'; const genreFamily = '22222222-2222-4222-8222-222222222222'; const mechanicDice = '33333333-3333-4333-8333-333333333333'; +const mechanicCards = '44444444-4444-4444-8444-444444444444'; const testGenres = [ CatalogTerm(id: genreStrategy, nameDe: 'Strategie', nameEn: 'Strategy'), diff --git a/app/test/features/catalog/data/supabase_catalog_repository_test.dart b/app/test/features/catalog/data/supabase_catalog_repository_test.dart index 706e443..dcb547c 100644 --- a/app/test/features/catalog/data/supabase_catalog_repository_test.dart +++ b/app/test/features/catalog/data/supabase_catalog_repository_test.dart @@ -126,6 +126,31 @@ void main() { expect(body(requests.single), isNot(contains('p_mechanic_ids'))); }); + test( + 'KAT-01 Kriterium 4: several genre and mechanic IDs are all sent', + () async { + await repository.search( + const CatalogSearchQuery( + filters: CatalogFilters( + genreIds: {genreStrategy, genreFamily}, + mechanicIds: {mechanicDice, mechanicCards}, + ), + ), + offset: 0, + ); + + final sent = body(requests.single); + expect( + sent['p_genre_ids'], + unorderedEquals([genreStrategy, genreFamily]), + ); + expect( + sent['p_mechanic_ids'], + unorderedEquals([mechanicDice, mechanicCards]), + ); + }, + ); + test('KAT-01 Kriterium 4: mechanics are sent as p_mechanic_ids', () async { await repository.search( const CatalogSearchQuery( @@ -285,7 +310,7 @@ void main() { expect(result.errorOrNull, isA()); }); - test('QUE-04: logs the failure kind, never the search text', () async { + test('QUE-04: the repository logger never logs the search text', () async { const secret = 'geheimer-suchbegriff'; final records = []; Logger.root.level = Level.ALL; @@ -304,6 +329,17 @@ void main() { offset: 0, ); + // Any other library may echo the text in its own debug logs (postgrest + // does at FINE); AppLogger drops those, so none of them is written. + for (final record in records.where( + (r) => '${r.message} ${r.error}'.contains(secret), + )) { + expect( + AppLogger.shouldWrite(record), + isFalse, + reason: '${record.loggerName} would write the search text', + ); + } final catalog = records.where((r) => r.loggerName == 'CatalogRepository'); expect(catalog, isNotEmpty); for (final record in catalog) { diff --git a/app/test/features/catalog/domain/catalog_domain_test.dart b/app/test/features/catalog/domain/catalog_domain_test.dart index 6ad95d9..5f76b79 100644 --- a/app/test/features/catalog/domain/catalog_domain_test.dart +++ b/app/test/features/catalog/domain/catalog_domain_test.dart @@ -41,6 +41,35 @@ void main() { expect(CatalogSearchQuery.cleanText('a' * 150), hasLength(100)); }); + test( + 'Eingabegrenzen: the text is cut to 10 words (rest dropped silently)', + () { + expect( + CatalogSearchQuery.cleanText('a b c d e f g h i j k l'), + 'a b c d e f g h i j', + ); + expect( + CatalogSearchQuery.cleanText('a b c d e f g h i j'), + 'a b c d e f g h i j', + ); + expect(CatalogSearchQuery.cleanText('a b c'), 'a b c'); + }, + ); + + test('Eingabegrenzen: punctuation separates words like in the backend', () { + // Hyphen and colon split words: 12 words, not 6. + expect( + CatalogSearchQuery.cleanText('a-b c:d e-f g-h i-j k-l'), + 'a-b c:d e-f g-h i-j', + ); + expect( + CatalogSearchQuery.cleanText( + 'Æon’s End: Ünïcode Wörter ß 1 2 3 4 5 6 7', + ), + 'Æon’s End: Ünïcode Wörter ß 1 2 3 4', + ); + }); + test('KAT-01 Kriterium 4: activeFilterCount counts every filter value', () { const filters = CatalogFilters( players: 3, diff --git a/app/test/features/catalog/presentation/search/catalog_search_behavior_test.dart b/app/test/features/catalog/presentation/search/catalog_search_behavior_test.dart index 05663f2..558c214 100644 --- a/app/test/features/catalog/presentation/search/catalog_search_behavior_test.dart +++ b/app/test/features/catalog/presentation/search/catalog_search_behavior_test.dart @@ -3,6 +3,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.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 '../../catalog_test_support.dart'; import 'catalog_search_test_support.dart'; @@ -142,6 +143,34 @@ void main() { expect(de, lessThan(de2)); }); + testWidgets('terms with umlauts sort next to their base letters', ( + tester, + ) async { + final repository = FakeCatalogRepository( + all: [game(1)], + genres: const [ + CatalogTerm(id: genreStrategy, nameDe: 'Zocken', nameEn: 'Z'), + CatalogTerm(id: genreFamily, nameDe: 'Ökonomie', nameEn: 'E'), + CatalogTerm( + id: '55555555-5555-4555-8555-555555555555', + nameDe: 'Party', + nameEn: 'P', + ), + ], + ); + await pumpView(tester, repository); + await openFilterSheet(tester); + + final party = tester.getTopLeft(find.text('Party')); + final oekonomie = tester.getTopLeft(find.text('Ökonomie')); + final zocken = tester.getTopLeft(find.text('Zocken')); + bool before(Offset a, Offset b) => + a.dy < b.dy || (a.dy == b.dy && a.dx < b.dx); + // Ö sorts as O: Ökonomie, Party, Zocken. + expect(before(oekonomie, party), isTrue); + expect(before(party, zocken), isTrue); + }); + testWidgets('QUE-05 Kriterium 4: English names and order', (tester) async { final repository = FakeCatalogRepository(all: [game(1)]); await pumpView(tester, repository, locale: const Locale('en')); diff --git a/app/test/features/catalog/presentation/search/catalog_search_controller_test.dart b/app/test/features/catalog/presentation/search/catalog_search_controller_test.dart index d3b1637..ae9ff5e 100644 --- a/app/test/features/catalog/presentation/search/catalog_search_controller_test.dart +++ b/app/test/features/catalog/presentation/search/catalog_search_controller_test.dart @@ -302,6 +302,31 @@ void main() { expect(state().loadingMore, isFalse); }); + run('KAT-01 Kriterium 5: a stale loadMore answer is not appended', ( + tester, + ) async { + final repo = FakeCatalogRepository(manual: true); + useRepository(repo); + controller().init(const CatalogSearchQuery()); + await tester.pump(); + repo.complete(0, items: [game(1)], totalCount: 3); + await tester.pump(); + unawaited(controller().loadMore()); + await tester.pump(); + + // A new search starts while the next page is still on its way ... + controller().submitText('neu'); + await tester.pump(); + // ... and the old page arrives before the new answer. + repo.complete(1, items: [game(2), game(3)], totalCount: 3); + await tester.pump(); + expect(state().items.map((g) => g.id), ['game-1']); + + repo.complete(2, items: [game(9, title: 'Neu')], totalCount: 1); + await tester.pump(); + expect(state().items.map((g) => g.title), ['Neu']); + }); + run('KAT-01 Kriterium 5: an error while loading more keeps the hits', ( tester, ) async { 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 5038239..e42cd99 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 @@ -82,6 +82,26 @@ void main() { expect(repository.calls.first.query.isEmpty, isTrue); }); + testWidgets('Android: system back from a deep link returns to the start ' + 'page instead of closing the app', (tester) async { + useWindow(tester, 420); + final repository = FakeCatalogRepository(all: [game(1)]); + final container = await pumpShuffleApp( + tester, + overrides: catalogOverrides(repository), + ); + container.read(appRouterProvider).go('/catalog?q=catan'); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('catalog_search_screen')), findsOneWidget); + + // The framework's system back; `false` would mean the app closes. + final handled = await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + + expect(handled, isTrue); + expect(find.byKey(const Key('home_screen')), findsOneWidget); + }); + testWidgets('start page entry opens the search and back returns', ( tester, ) async { @@ -133,7 +153,13 @@ void main() { ), ); final router = buildTestRouter( - routes: catalogRoutes, + routes: [ + GoRoute( + path: '/', + builder: (context, state) => const SizedBox(), + routes: catalogRoutes, + ), + ], initialLocation: initialLocation, ); addTearDown(router.dispose); diff --git a/app/test/features/catalog/presentation/search/catalog_search_view_test.dart b/app/test/features/catalog/presentation/search/catalog_search_view_test.dart index 99cf86d..042f68d 100644 --- a/app/test/features/catalog/presentation/search/catalog_search_view_test.dart +++ b/app/test/features/catalog/presentation/search/catalog_search_view_test.dart @@ -362,6 +362,23 @@ void main() { expect(find.byKey(const Key('catalog_error')), findsNothing); }); + testWidgets('rejected input shows an own text and offers no retry', ( + tester, + ) async { + final repository = FakeCatalogRepository() + ..failWith = const CatalogFailure.invalidInput(); + + await pumpView(tester, repository); + + expect( + find.text( + 'Diese Suche wird nicht unterstützt. Ändere den Suchbegriff oder die Filter.', + ), + findsOneWidget, + ); + expect(find.byKey(const Key('catalog_error_retry')), findsNothing); + }); + testWidgets('Zustände Kriterium 2: other errors show the generic text', ( tester, ) async { diff --git a/docs/design-system.md b/docs/design-system.md index b9b0239..d56eb0a 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -87,13 +87,13 @@ Formular-Komponenten (Texte von außen, Tests gegen die Guidelines in Hell und D | Komponente | Zweck und API (Kurzform) | |---|---| | `AppStateMessage` | Leer-/Fehlerzustand: `icon` (dekorativ, für Screenreader ausgeblendet), `title`, optional `message`, `actions` (`AppStateAction(label, onPressed, key)`; die erste Aktion ist tonal hervorgehoben, weitere umrandet; alle 48 dp). Titel und Hinweis sind eine Live-Region. | -| `AppListFooter` | Fuß einer seitenweise geladenen Liste: `isLoading` (beschrifteter Fortschrittskreis), `hasMore` (Schaltfläche „Weitere laden“, per Tastatur und Screenreader erreichbar, optional `loadMoreFocusNode`), `errorMessage` + Wiederholen (bereits geladene Einträge bleiben). Vorrang: Fehler vor Laden vor „Weitere laden“. Automatisches Nachladen ist Sache des Aufrufers; die Schaltfläche bleibt der Weg für Tastatur und Screenreader. | +| `AppListFooter` | Fuß einer seitenweise geladenen Liste: `isLoading` (beschrifteter Fortschrittskreis), `hasMore` (Schaltfläche „Weitere laden“, per Tastatur und Screenreader erreichbar), `errorMessage` + Wiederholen (bereits geladene Einträge bleiben). Vorrang: Fehler vor Laden vor „Weitere laden“. Automatisches Nachladen ist Sache des Aufrufers; die Schaltfläche bleibt der Weg für Tastatur und Screenreader. | **Katalogsuche (`features/catalog/presentation/search/`)** – Aufbau und Verhalten, Entscheidungen der Umsetzung (Spec #3 § 5.6): - Kompakt (< 600 dp) und mittel (600–1023 dp): Suchfeld, darunter die Schaltfläche „Filter (n)“ mit Bottom Sheet (Änderungen wirken sofort, „Fertig“ schließt); ab 1024 dp feste Seitenleiste (320 dp) links, Trefferliste rechts mit höchstens 720 dp Breite. - Einfachauswahl (Spielerzahl, Dauer, Alter) als beschriftete Auswahlfelder mit „Beliebig“; Genre und Mechanik als Filter-Chips, nach lokalisiertem Namen sortiert. Fehler beim Laden der Begriffe betrifft nur diese beiden Bereiche („Erneut versuchen“). -- Trefferkachel: `GameCover` klein (Platzhalter), Titel (2 Zeilen), „auch: …“, Jahr · Verlage (zwei, sonst „+n“), Kurzangaben; ein zusammengefasstes Semantics-Label. Ohne `onTap` ist die Kachel kein Button, aber per Code fokussierbar (Fokusring 3 dp, kein Tab-Stopp). +- Trefferkachel: `GameCover` klein (Platzhalter), Titel (2 Zeilen), „auch: …“, Jahr · Verlage (zwei, sonst „+n“), Kurzangaben; ein zusammengefasstes Semantics-Label. Ohne `onTap` ist die Kachel kein Button, aber ein Tab-Stopp mit Fokusring (3 dp): Tastaturnutzer durchlaufen so die Liste, was sie scrollt und das Nachladen auslöst (die Liste wird lazy gebaut). Im Screenreader-Durchgang bewerten, ob die vielen Tab-Stopps stören. - Trefferzahl über der Liste; Ansage der Trefferzahl 600 ms nach Abschluss der letzten Suche (`SemanticsService.sendAnnouncement`), nie während des Tippens. - Nach „Weitere laden“ (bzw. „Erneut versuchen“ im Listenfuß) springt der Fokus auf die erste neu geladene Kachel. Automatisches Nachladen, wenn weniger als etwa fünf Kacheln unterhalb des sichtbaren Bereichs liegen; nach einem Fehler nur noch über die Schaltfläche (keine Wiederholungsschleife). From 668fdf17057e4ed978438799fcc0ce5007d03a16 Mon Sep 17 00:00:00 2001 From: cschlaefke Date: Wed, 4 Nov 2026 10:39:01 +0100 Subject: [PATCH 8/8] fix(catalog): map 22003 and 22P02 to invalidInput Refs #3 Co-Authored-By: Claude Sonnet 5.5 --- app/lib/features/catalog/data/catalog_failure_mapper.dart | 6 +++++- app/test/features/catalog/domain/catalog_domain_test.dart | 7 +++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/app/lib/features/catalog/data/catalog_failure_mapper.dart b/app/lib/features/catalog/data/catalog_failure_mapper.dart index e47769d..b9e88e1 100644 --- a/app/lib/features/catalog/data/catalog_failure_mapper.dart +++ b/app/lib/features/catalog/data/catalog_failure_mapper.dart @@ -5,6 +5,8 @@ import 'package:shuffle/core/platform/io_error.dart'; import 'package:shuffle/features/catalog/domain/catalog_failure.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; +const _invalidInputCodes = {'P0001', '22003', '22P02'}; + /// Translates exceptions of `supabase_flutter` into a [CatalogFailure] /// (Spec #3 § 5.2). CatalogFailure mapCatalogError(Object error) { @@ -13,7 +15,9 @@ CatalogFailure mapCatalogError(Object error) { isIoNetworkError(error)) { return const CatalogFailure.network(); } - if (error is PostgrestException && error.code == 'P0001') { + // P0001: RPC input checks; 22003: number out of range (smallint cast); + // 22P02: invalid text representation. None gets better by repeating. + if (error is PostgrestException && _invalidInputCodes.contains(error.code)) { return const CatalogFailure.invalidInput(); } return const CatalogFailure.unexpected(); diff --git a/app/test/features/catalog/domain/catalog_domain_test.dart b/app/test/features/catalog/domain/catalog_domain_test.dart index 5f76b79..3092fe1 100644 --- a/app/test/features/catalog/domain/catalog_domain_test.dart +++ b/app/test/features/catalog/domain/catalog_domain_test.dart @@ -182,6 +182,13 @@ void main() { ), isA(), ); + for (final code in ['22003', '22P02']) { + expect( + mapCatalogError(PostgrestException(message: 'x', code: code)), + isA(), + reason: code, + ); + } expect( mapCatalogError(const PostgrestException(message: 'x', code: '42501')), isA(),