feat(catalog): catalog search feature with domain, data layer, controller and screen

Refs #3

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
cschlaefke 2026-11-04 01:08:51 +01:00
parent 8c003ddece
commit 0775152870
51 changed files with 6762 additions and 4 deletions

View file

@ -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);

View file

@ -48,4 +48,4 @@ final class AppRouterProvider
}
}
String _$appRouterHash() => r'39ea53d71fba02f9e9cc5e42df61144e55eca3d9';
String _$appRouterHash() => r'e5b9cf23cd96b28b8941552d226d592a1e06efc1';

View file

@ -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';

View file

@ -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();

View file

@ -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<UrlReplacer, UrlReplacer, UrlReplacer>
with $Provider<UrlReplacer> {
/// 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<UrlReplacer> $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<UrlReplacer>(value),
);
}
}
String _$urlReplacerHash() => r'84773dd072e19ea69ca1b6438f9f7f5dea7f44d9';

View file

@ -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),
);
}
}

View file

@ -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<AppStateAction> 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),
),
],
],
),
),
),
);
}
}

View file

@ -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();
}

View file

@ -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<String, dynamic> 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: <String>[])
final List<String> 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,
);
}

View file

@ -0,0 +1,27 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'catalog_game_summary_dto.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
CatalogGameSummaryDto _$CatalogGameSummaryDtoFromJson(
Map<String, dynamic> json,
) => CatalogGameSummaryDto(
id: json['id'] as String,
title: json['title'] as String,
publisherNames:
(json['publisher_names'] as List<dynamic>?)
?.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?,
);

View file

@ -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<String, dynamic> json) =>
_$CatalogTermDtoFromJson(json);
final String id;
final String nameDe;
final String nameEn;
CatalogTerm toDomain() => CatalogTerm(id: id, nameDe: nameDe, nameEn: nameEn);
}

View file

@ -0,0 +1,14 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'catalog_term_dto.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
CatalogTermDto _$CatalogTermDtoFromJson(Map<String, dynamic> json) =>
CatalogTermDto(
id: json['id'] as String,
nameDe: json['name_de'] as String,
nameEn: json['name_en'] as String,
);

View file

@ -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<String, dynamic> 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<Result<CatalogSearchPage, CatalogFailure>> 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<dynamic>('search_catalog_games', params: params)
.timeout(timeout);
final rows = (response as List<dynamic>).cast<Map<String, dynamic>>();
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<Result<List<CatalogTerm>, CatalogFailure>> fetchGenres() =>
_fetchTerms('genres');
@override
Future<Result<List<CatalogTerm>, CatalogFailure>> fetchMechanics() =>
_fetchTerms('mechanics');
Future<Result<List<CatalogTerm>, 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;
}
}

View file

@ -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;
}

View file

@ -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>(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 extends Object?>({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<TResult extends Object?>({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 extends Object?>({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 extends Object?>({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<TResult extends Object?>({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 extends Object?>({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

View file

@ -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(<String>[]) List<String> publisherNames,
/// Alternative title that matched better than the main title.
String? matchedAltTitle,
}) = _CatalogGameSummary;
}

View file

@ -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>(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<String> 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<CatalogGameSummary> get copyWith => _$CatalogGameSummaryCopyWithImpl<CatalogGameSummary>(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<String> 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<String>,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 extends Object?>(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 extends Object?>(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 extends Object?>(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 extends Object?>(TResult Function( String id, String title, int? year, int? minPlayers, int? maxPlayers, int? minMinutes, int? maxMinutes, int? minAge, String? imagePath, List<String> 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 extends Object?>(TResult Function( String id, String title, int? year, int? minPlayers, int? maxPlayers, int? minMinutes, int? maxMinutes, int? minAge, String? imagePath, List<String> 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 extends Object?>(TResult? Function( String id, String title, int? year, int? minPlayers, int? maxPlayers, int? minMinutes, int? maxMinutes, int? minAge, String? imagePath, List<String> 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<String> publisherNames = const <String>[], 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<String> _publisherNames;
@override@JsonKey() List<String> 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<String> 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<String>,matchedAltTitle: freezed == matchedAltTitle ? _self.matchedAltTitle : matchedAltTitle // ignore: cast_nullable_to_non_nullable
as String?,
));
}
}
// dart format on

View file

@ -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<Result<CatalogSearchPage, CatalogFailure>> search(
CatalogSearchQuery query, {
required int offset,
int limit = CatalogSearchLimits.pageSize,
});
Future<Result<List<CatalogTerm>, CatalogFailure>> fetchGenres();
Future<Result<List<CatalogTerm>, CatalogFailure>> fetchMechanics();
}

View file

@ -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<CatalogGameSummary> items,
required int totalCount,
}) = _CatalogSearchPage;
}

View file

@ -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>(T value) => value;
/// @nodoc
mixin _$CatalogSearchPage {
List<CatalogGameSummary> 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<CatalogSearchPage> get copyWith => _$CatalogSearchPageCopyWithImpl<CatalogSearchPage>(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<CatalogGameSummary> 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<CatalogGameSummary>,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 extends Object?>(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 extends Object?>(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 extends Object?>(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 extends Object?>(TResult Function( List<CatalogGameSummary> 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 extends Object?>(TResult Function( List<CatalogGameSummary> 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 extends Object?>(TResult? Function( List<CatalogGameSummary> 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<CatalogGameSummary> items, required this.totalCount}): _items = items;
final List<CatalogGameSummary> _items;
@override List<CatalogGameSummary> 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<CatalogGameSummary> 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<CatalogGameSummary>,totalCount: null == totalCount ? _self.totalCount : totalCount // ignore: cast_nullable_to_non_nullable
as int,
));
}
}
// dart format on

View file

@ -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<int> maxMinutesSteps = [30, 45, 60, 90, 120, 180];
/// "Suitable from X years" steps.
static const List<int> 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(<String>{}) Set<String> genreIds,
@Default(<String>{}) Set<String> 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: '');
}

View file

@ -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>(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<String> get genreIds; Set<String> 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<CatalogFilters> get copyWith => _$CatalogFiltersCopyWithImpl<CatalogFilters>(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<String> genreIds, Set<String> 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<String>,mechanicIds: null == mechanicIds ? _self.mechanicIds : mechanicIds // ignore: cast_nullable_to_non_nullable
as Set<String>,
));
}
}
/// 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 extends Object?>(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 extends Object?>(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 extends Object?>(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 extends Object?>(TResult Function( int? players, int? maxMinutes, int? minAgeSuitable, Set<String> genreIds, Set<String> 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 extends Object?>(TResult Function( int? players, int? maxMinutes, int? minAgeSuitable, Set<String> genreIds, Set<String> 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 extends Object?>(TResult? Function( int? players, int? maxMinutes, int? minAgeSuitable, Set<String> genreIds, Set<String> 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<String> genreIds = const <String>{}, Set<String> mechanicIds = const <String>{}}): 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<String> _genreIds;
/// At most [CatalogSearchLimits.maxTerms]; the UI and the URL parser
/// enforce it.
@override@JsonKey() Set<String> get genreIds {
if (_genreIds is EqualUnmodifiableSetView) return _genreIds;
// ignore: implicit_dynamic_type
return EqualUnmodifiableSetView(_genreIds);
}
final Set<String> _mechanicIds;
@override@JsonKey() Set<String> 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<String> genreIds, Set<String> 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<String>,mechanicIds: null == mechanicIds ? _self._mechanicIds : mechanicIds // ignore: cast_nullable_to_non_nullable
as Set<String>,
));
}
}
/// @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<CatalogSearchQuery> get copyWith => _$CatalogSearchQueryCopyWithImpl<CatalogSearchQuery>(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 extends Object?>(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 extends Object?>(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 extends Object?>(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 extends Object?>(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 extends Object?>(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 extends Object?>(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

View file

@ -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;
}

View file

@ -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>(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<CatalogTerm> get copyWith => _$CatalogTermCopyWithImpl<CatalogTerm>(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 extends Object?>(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 extends Object?>(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 extends Object?>(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 extends Object?>(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 extends Object?>(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 extends Object?>(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

View file

@ -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<String, String> params) {
int? number(String key, bool Function(int) allowed) {
final value = int.tryParse(params[key] ?? '');
return value != null && allowed(value) ? value : null;
}
Set<String> ids(String key) {
final raw = params[key];
if (raw == null || raw.isEmpty) {
return const {};
}
final result = <String>{};
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<String, String> 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);
}

View file

@ -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<RouteBase> catalogRoutes = [
GoRoute(
path: AppRoutes.catalog,
builder: (context, state) => CatalogSearchScreen(
initialQuery: parseCatalogQuery(state.uri.queryParameters),
),
),
];

View file

@ -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<List<CatalogTerm>> 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<List<CatalogTerm>> catalogMechanics(Ref ref) async {
final result = await ref.watch(catalogRepositoryProvider).fetchMechanics();
return result.when(success: (terms) => terms, failure: (f) => throw f);
}

View file

@ -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<CatalogRepository> {
CatalogRepositoryProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'catalogRepositoryProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$catalogRepositoryHash();
@$internal
@override
$ProviderElement<CatalogRepository> $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<CatalogRepository>(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<CatalogTerm>>,
List<CatalogTerm>,
FutureOr<List<CatalogTerm>>
>
with
$FutureModifier<List<CatalogTerm>>,
$FutureProvider<List<CatalogTerm>> {
/// 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<List<CatalogTerm>> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<List<CatalogTerm>> 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<CatalogTerm>>,
List<CatalogTerm>,
FutureOr<List<CatalogTerm>>
>
with
$FutureModifier<List<CatalogTerm>>,
$FutureProvider<List<CatalogTerm>> {
/// 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<List<CatalogTerm>> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<List<CatalogTerm>> create(Ref ref) {
return catalogMechanics(ref);
}
}
String _$catalogMechanicsHash() => r'4a7b95bb44d3aa59748754e55b4802b9bf16c44c';

View file

@ -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<void> 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<void> retry() async {
if (state.status == CatalogSearchStatus.error) {
await _search(force: true);
} else if (state.loadMoreFailure != null) {
state = state.copyWith(loadMoreFailure: null);
await loadMore();
}
}
Future<void> _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,
);
},
);
}
}

View file

@ -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<CatalogSearchController, CatalogSearchState> {
/// 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<CatalogSearchState>(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> {
CatalogSearchState build();
@$mustCallSuper
@override
WhenComplete runBuild() {
final ref = this.ref as $Ref<CatalogSearchState, CatalogSearchState>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<CatalogSearchState, CatalogSearchState>,
CatalogSearchState,
Object?,
Object?
>;
return element.handleCreate(ref, build);
}
}

View file

@ -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<String>? 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,
),
);
}
}

View file

@ -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(<CatalogGameSummary>[]) List<CatalogGameSummary> 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;
}

View file

@ -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>(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<CatalogGameSummary> 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<CatalogSearchState> get copyWith => _$CatalogSearchStateCopyWithImpl<CatalogSearchState>(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<CatalogGameSummary> 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<CatalogGameSummary>,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 extends Object?>(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 extends Object?>(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 extends Object?>(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 extends Object?>(TResult Function( CatalogSearchQuery query, CatalogSearchQuery searchedQuery, CatalogSearchStatus status, List<CatalogGameSummary> 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 extends Object?>(TResult Function( CatalogSearchQuery query, CatalogSearchQuery searchedQuery, CatalogSearchStatus status, List<CatalogGameSummary> 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 extends Object?>(TResult? Function( CatalogSearchQuery query, CatalogSearchQuery searchedQuery, CatalogSearchStatus status, List<CatalogGameSummary> 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<CatalogGameSummary> items = const <CatalogGameSummary>[], 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<CatalogGameSummary> _items;
@override@JsonKey() List<CatalogGameSummary> 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<CatalogGameSummary> 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<CatalogGameSummary>,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

View file

@ -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<String>? 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<CatalogSearchView> 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<CatalogSearchView> {
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<void> _openFilters() {
final l10n = context.l10n;
return showModalBottomSheet<void>(
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<ScrollNotification>(
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,
);
},
),
),
),
],
);
}
}

View file

@ -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<String>? 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,
);
}
}

View file

@ -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<CatalogFilters> 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<int> options;
final String Function(int) optionLabel;
final ValueChanged<int?> onChanged;
@override
Widget build(BuildContext context) {
return KeyedSubtree(
key: fieldKey,
child: DropdownButtonFormField<int?>(
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<int?>(child: Text(anyLabel)),
for (final option in options)
DropdownMenuItem<int?>(
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<List<CatalogTerm>> terms;
final VoidCallback onRetry;
final Set<String> selected;
final ValueChanged<Set<String>> 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,
],
);
}
}

View file

@ -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<String> 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<CatalogGameTile> createState() => _CatalogGameTileState();
}
class _CatalogGameTileState extends State<CatalogGameTile> {
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,
),
),
),
),
);
}
}

View file

@ -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<String> onChanged;
/// Called on Enter and when the text is cleared: search without debounce.
final ValueChanged<String> onSubmitted;
@override
State<CatalogSearchField> createState() => _CatalogSearchFieldState();
}
class _CatalogSearchFieldState extends State<CatalogSearchField> {
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<TextEditingValue>(
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,
),
),
);
}
}

View file

@ -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),
);
}
}

View file

@ -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),

View file

@ -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."
}

View file

@ -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"}
}

View file

@ -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

View file

@ -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.';
}

View file

@ -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.';
}

View file

@ -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<String> 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<Result<CatalogSearchPage, CatalogFailure>> 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<CatalogGameSummary> all;
bool manual;
List<CatalogTerm> genres;
List<CatalogTerm> mechanics;
/// Failure to return instead of results (null = none).
CatalogFailure? failWith;
CatalogFailure? termsFailWith;
final List<SearchCall> calls = [];
@override
Future<Result<CatalogSearchPage, CatalogFailure>> search(
CatalogSearchQuery query, {
required int offset,
int limit = CatalogSearchLimits.pageSize,
}) {
final completer = Completer<Result<CatalogSearchPage, CatalogFailure>>();
final call = SearchCall(query, offset, limit, completer);
calls.add(call);
if (!manual) {
completer.complete(_answer(offset, limit));
}
return completer.future;
}
Result<CatalogSearchPage, CatalogFailure> _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<CatalogGameSummary>? 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<Result<List<CatalogTerm>, CatalogFailure>> fetchGenres() async {
final failure = termsFailWith;
return failure != null ? Result.failure(failure) : Result.success(genres);
}
@override
Future<Result<List<CatalogTerm>, CatalogFailure>> fetchMechanics() async {
final failure = termsFailWith;
return failure != null
? Result.failure(failure)
: Result.success(mechanics);
}
}
/// Overrides with [repository] as catalog repository.
List<Override> catalogOverrides(FakeCatalogRepository repository) => [
...testOverrides(),
catalogRepositoryProvider.overrideWithValue(repository),
];

View file

@ -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<http.Request> 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<String, dynamic> body(http.Request request) =>
jsonDecode(request.body) as Map<String, dynamic>;
Map<String, dynamic> 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<CatalogUnexpectedFailure>());
});
});
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<CatalogNetworkFailure>());
});
test('Zustände Kriterium 3: timeout maps to network', () async {
final slow = SupabaseCatalogRepository(
SupabaseClient(
'http://localhost:54321',
'anon-key',
httpClient: MockClient((request) {
return Completer<http.Response>().future;
}),
),
timeout: const Duration(milliseconds: 20),
);
final result = await slow.search(const CatalogSearchQuery(), offset: 0);
expect(result.errorOrNull, isA<CatalogNetworkFailure>());
});
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<CatalogInvalidInputFailure>());
});
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<CatalogUnexpectedFailure>());
});
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<CatalogUnexpectedFailure>());
});
test('QUE-04: logs the failure kind, never the search text', () async {
const secret = 'geheimer-suchbegriff';
final records = <LogRecord>[];
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);
}
});
});
}

View file

@ -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<AssertionError>()));
expect(() => CatalogFilters(players: 13), throwsA(isA<AssertionError>()));
expect(
() => CatalogSearchQuery(text: 'a' * 101),
throwsA(isA<AssertionError>()),
);
});
});
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<CatalogNetworkFailure>(),
);
expect(
mapCatalogError(const SocketException('down')),
isA<CatalogNetworkFailure>(),
);
expect(
mapCatalogError(http.ClientException('down')),
isA<CatalogNetworkFailure>(),
);
});
test('P0001 is invalidInput, other Postgrest errors are unexpected', () {
expect(
mapCatalogError(
const PostgrestException(
message: 'search_filter_invalid',
code: 'P0001',
),
),
isA<CatalogInvalidInputFailure>(),
);
expect(
mapCatalogError(const PostgrestException(message: 'x', code: '42501')),
isA<CatalogUnexpectedFailure>(),
);
expect(mapCatalogError(StateError('x')), isA<CatalogUnexpectedFailure>());
});
});
}

View file

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

View file

@ -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<void> 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<CatalogNetworkFailure>());
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<CatalogNetworkFailure>());
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);
});
}

View file

@ -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<void> pumpView(
WidgetTester tester,
FakeCatalogRepository repository, {
CatalogSearchQuery initialQuery = const CatalogSearchQuery(),
ValueChanged<String>? 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<GameCover>(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<TextField>(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<TextField>(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<TextField>(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<TextField>(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<TextField>(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);
});
});
}