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); + }); + }); +}