Compare commits

...

10 commits

Author SHA1 Message Date
cc9d0aa191 Merge pull request 'feat(catalog): Katalogsuche in der App (KAT-01, 3b App)' (#63) from feature/3b-katalog-suche-app into main
All checks were successful
CI / flutter (push) Successful in 1m30s
CI / web-build (push) Successful in 57s
CI / supabase (push) Successful in 8s
CI / supabase-tests (push) Successful in 32s
CI / e2e (push) Successful in 2m59s
Reviewed-on: #63
2026-11-04 17:29:11 +00:00
cschlaefke
6020801fff chore: merge main into feature/3b-katalog-suche-app
All checks were successful
CI / flutter (pull_request) Successful in 1m19s
CI / web-build (pull_request) Successful in 56s
CI / supabase (pull_request) Successful in 9s
CI / supabase-tests (pull_request) Successful in 31s
CI / e2e (pull_request) Successful in 3m0s
Refs #3

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 11:06:16 +01:00
cschlaefke
668fdf1705 fix(catalog): map 22003 and 22P02 to invalidInput
All checks were successful
CI / flutter (pull_request) Successful in 1m21s
CI / web-build (pull_request) Successful in 54s
CI / supabase (pull_request) Successful in 8s
CI / supabase-tests (pull_request) Successful in 35s
CI / e2e (pull_request) Successful in 2m54s
Refs #3

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 10:39:01 +01:00
cschlaefke
25212929ce feat(catalog): review fixes for catalog search
All checks were successful
CI / flutter (pull_request) Successful in 1m46s
CI / web-build (pull_request) Successful in 59s
CI / supabase (pull_request) Successful in 12s
CI / supabase-tests (pull_request) Successful in 31s
CI / e2e (pull_request) Successful in 2m54s
Word limit and no-retry state for rejected input, nested /catalog route for
system back, logger filter for supabase libs, more mutation-proof tests,
doc fix, umlaut-aware term sorting, thousands separators. Refs #3

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 10:17:54 +01:00
cschlaefke
ae099eb3d7 feat(catalog): labeled start page button, prune unknown term IDs from the URL
Refs #3

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 09:59:54 +01:00
cschlaefke
2d18a49fc2 fix(catalog): keyboard reach of the result list, tab order, stable select focus
Tiles are tab stops again, tab order is search field, filters, list (also
with the side bar), selects keep focus after a choice. Failure kind is
logged with stable names. Refs #3

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 01:25:23 +01:00
cschlaefke
80b548819b docs(design): list components and catalog search behavior
Refs #3

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 01:18:23 +01:00
cschlaefke
3b66f4afb0 test(catalog): widget, a11y, URL sync and controller tests for catalog search
KAT-01 Kriterium 1-6 and 8, QUE-05, QUE-06. Refs #3

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 01:18:23 +01:00
cschlaefke
28404edd10 fix(catalog): check auto-load also on size changes
Refs #3

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 01:18:23 +01:00
cschlaefke
0775152870 feat(catalog): catalog search feature with domain, data layer, controller and screen
Refs #3

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 01:08:51 +01:00
59 changed files with 8240 additions and 3 deletions

View file

@ -48,7 +48,17 @@ class AppLogger {
_subscription = null;
}
/// Whether a record is written. Records of the Supabase libraries below
/// INFO are dropped: postgrest logs `'$error from request'` at FINE, and the
/// error text can echo request parameters (search terms, QUE-04).
@visibleForTesting
static bool shouldWrite(LogRecord record) =>
!(record.loggerName.startsWith('supabase') && record.level < Level.INFO);
static void _write(LogRecord record) {
if (!shouldWrite(record)) {
return;
}
developer.log(
record.message,
time: record.time,

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';
@ -23,6 +24,7 @@ GoRouter appRouter(Ref ref) {
GoRoute(
path: AppRoutes.home,
builder: (context, state) => const HomeScreen(),
routes: catalogRoutes,
),
...authRoutes,
],

View file

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

View file

@ -2,6 +2,12 @@
abstract final class AppRoutes {
static const home = '/';
/// Katalogsuche, öffentlich (Spec #3 § 5.5).
static const catalog = '/catalog';
/// Segment of [catalog] below [home] (nested route).
static const catalogSegment = 'catalog';
// Auth (Spec #1 § 5.3)
static const login = '/login';
static const register = '/register';

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,88 @@
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,
});
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;
@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'),
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,24 @@
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';
const _invalidInputCodes = {'P0001', '22003', '22P02'};
/// 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();
}
// P0001: RPC input checks; 22003: number out of range (smallint cast);
// 22P02: invalid text representation. None gets better by repeating.
if (error is PostgrestException && _invalidInputCodes.contains(error.code)) {
return const CatalogFailure.invalidInput();
}
return const CatalogFailure.unexpected();
}

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,116 @@
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 only.
CatalogFailure _fail(String operation, Object error) {
final failure = mapCatalogError(error);
// Stable names (runtime type names are minified on the web).
final kind = switch (failure) {
CatalogNetworkFailure() => 'network',
CatalogInvalidInputFailure() => 'invalidInput',
CatalogUnexpectedFailure() => 'unexpected',
};
_log.warning('Catalog $operation failed: $kind');
return failure;
}
}

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,117 @@
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];
/// The backend rejects search texts with more words.
static const int maxWords = 10;
/// Most genres or mechanics that can be selected at once.
static const int maxTerms = 20;
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._();
static final RegExp _word = RegExp(r'[\p{L}\p{N}]+', unicode: true);
/// Trims [raw] and cuts it to the maximum length and to at most
/// [CatalogSearchLimits.maxWords] words. The backend counts words after its
/// normalization (punctuation separates words, one-letter words count) and
/// rejects more; this is the client-side approximation, the rest is cut off
/// silently.
static String cleanText(String raw) {
var text = raw.trim();
if (text.length > CatalogSearchLimits.maxTextLength) {
text = text.substring(0, CatalogSearchLimits.maxTextLength).trim();
}
var count = 0;
for (final match in _word.allMatches(text)) {
if (++count == CatalogSearchLimits.maxWords) {
return text.substring(0, match.end);
}
}
return text;
}
/// Whether the text is long enough to count as a search term.
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,19 @@
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.
///
/// These are sub-routes of the start page (path `catalog` below `/`), so the
/// URL stays `/catalog`, but a deep link builds the stack [home, catalog] and
/// the system back button (Android) returns to the start page instead of
/// closing the app.
final List<RouteBase> catalogRoutes = [
GoRoute(
path: AppRoutes.catalogSegment,
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,190 @@
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());
}
/// Drops genre IDs (from the URL) that are not in the vocabulary [known].
/// Searches again only if something was removed.
void retainKnownGenres(Set<String> known) {
final filters = state.query.filters;
final kept = filters.genreIds.where(known.contains).toSet();
if (kept.length != filters.genreIds.length) {
setFilters(filters.copyWith(genreIds: kept));
}
}
/// Like [retainKnownGenres] for mechanics.
void retainKnownMechanics(Set<String> known) {
final filters = state.query.filters;
final kept = filters.mechanicIds.where(known.contains).toSet();
if (kept.length != filters.mechanicIds.length) {
setFilters(filters.copyWith(mechanicIds: kept));
}
}
void clearFilters() => setFilters(const CatalogFilters());
/// Loads the next page of the shown results.
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'e75dae20e21a2595e02dd79004172f34c90ba90c';
/// 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,525 @@
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/domain/catalog_term.dart';
import 'package:shuffle/features/catalog/presentation/catalog_query_params.dart';
import 'package:shuffle/features/catalog/presentation/providers/catalog_providers.dart';
import 'package:shuffle/features/catalog/presentation/search/catalog_search_controller.dart';
import 'package:shuffle/features/catalog/presentation/search/catalog_search_state.dart';
import 'package:shuffle/features/catalog/presentation/search/widgets/catalog_empty_state.dart';
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;
final List<ProviderSubscription<Object?>> _termSubscriptions = [];
/// 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);
_pruneUnknownTerms();
}
});
}
/// IDs from the URL that the vocabulary does not know are dropped once it is
/// loaded (the search then runs again, the URL follows). A load error
/// removes nothing.
void _pruneUnknownTerms() {
final filters = widget.initialQuery.filters;
if (filters.genreIds.isNotEmpty) {
_termSubscriptions.add(
ref.listenManual<AsyncValue<List<CatalogTerm>>>(catalogGenresProvider, (
AsyncValue<List<CatalogTerm>>? previous,
AsyncValue<List<CatalogTerm>> next,
) {
final terms = next.value;
if (terms != null) {
_controller.retainKnownGenres({for (final t in terms) t.id});
}
}, fireImmediately: true),
);
}
if (filters.mechanicIds.isNotEmpty) {
_termSubscriptions.add(
ref.listenManual<AsyncValue<List<CatalogTerm>>>(
catalogMechanicsProvider,
(
AsyncValue<List<CatalogTerm>>? previous,
AsyncValue<List<CatalogTerm>> next,
) {
final terms = next.value;
if (terms != null) {
_controller.retainKnownMechanics({for (final t in terms) t.id});
}
},
fireImmediately: true,
),
);
}
}
@override
void dispose() {
_announceTimer?.cancel();
for (final subscription in _termSubscriptions) {
subscription.close();
}
_scroll.dispose();
_newTileFocus.dispose();
super.dispose();
}
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);
return LayoutBuilder(
builder: (context, constraints) {
// Also on a size change (without a state change): a larger window
// may show the whole list, so no scroll event would ever come.
if (state.status == CatalogSearchStatus.data) {
_scheduleAutoLoadCheck();
}
final width = constraints.maxWidth;
final expanded = AppWidthClass.of(width) == AppWidthClass.expanded;
final padding = AppSpacing.pagePadding(width);
final main = Align(
alignment: Alignment.topCenter,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: _maxResultWidth),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
FocusTraversalOrder(
order: const NumericFocusOrder(1),
child: Padding(
padding: EdgeInsets.fromLTRB(
padding.left,
padding.top,
padding.right,
AppSpacing.sm,
),
child: _buildHeader(state, showFilterButton: !expanded),
),
),
Expanded(
child: FocusTraversalOrder(
order: const NumericFocusOrder(3),
child: _buildResults(state, padding),
),
),
],
),
),
);
// Tab order (Spec #3 § 6): search field, filters, list. With the side
// bar left of the field the visual order alone would be wrong.
return FocusTraversalGroup(
policy: OrderedTraversalPolicy(),
child: expanded
? Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
FocusTraversalOrder(
order: const NumericFocusOrder(2),
child: SizedBox(
width: _sidebarWidth,
child: SingleChildScrollView(
key: const Key('catalog_filter_sidebar'),
padding: EdgeInsets.all(padding.left),
child: _buildSidebar(state),
),
),
),
const VerticalDivider(width: 1),
Expanded(child: main),
],
)
: main,
);
},
);
}
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 failure = state.failure;
final offline = failure is CatalogNetworkFailure;
// Rejected input does not get better by repeating it: no retry.
final invalid = failure is CatalogInvalidInputFailure;
return AppStateMessage(
key: const Key('catalog_error'),
icon: offline ? Icons.wifi_off : Icons.error_outline,
title: offline
? l10n.catalogSearchErrorOffline
: invalid
? l10n.catalogSearchErrorInvalid
: l10n.catalogSearchErrorGeneric,
actions: [
if (!invalid)
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,270 @@
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) {
// A controlled drop-down (no form field that keeps its own value), so a
// selection does not rebuild the field and keyboard focus stays on it.
return KeyedSubtree(
key: fieldKey,
child: InputDecorator(
// "Any" is a value too, so the label always floats.
isEmpty: false,
decoration: InputDecoration(labelText: label),
child: DropdownButtonHideUnderline(
child: DropdownButton<int?>(
value: value,
isExpanded: true,
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) =>
_sortKey(a.localizedName(language))
.compareTo(_sortKey(b.localizedName(language))),
);
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,
],
);
}
}
/// Sort key that puts umlauts and accents next to their base letters.
String _sortKey(String name) {
const map = {
'ä': 'a',
'ö': 'o',
'ü': 'u',
'ß': 'ss',
'é': 'e',
'è': 'e',
'ê': 'e',
'á': 'a',
'à': 'a',
'â': 'a',
'í': 'i',
'ó': 'o',
'ô': 'o',
'ú': 'u',
'ç': 'c',
'ñ': 'n',
'æ': 'ae',
'œ': 'oe',
};
final buffer = StringBuffer();
for (final rune in name.toLowerCase().runes) {
final char = String.fromCharCode(rune);
buffer.write(map[char] ?? char);
}
return buffer.toString();
}

View file

@ -0,0 +1,179 @@
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 stays a keyboard
/// stop with a visible focus ring, so keyboard users can walk the list (which
/// scrolls it and loads more) and focus can move to the first newly loaded
/// tile ([focusNode]).
class CatalogGameTile extends StatefulWidget {
const CatalogGameTile({
required this.game,
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,
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,21 @@
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) {
final label = context.l10n.catalogSearchTitle;
return FilledButton.icon(
key: const Key('home_catalog_search_button'),
onPressed: () => context.go(AppRoutes.catalog),
icon: const Icon(Icons.search),
label: Text(label),
);
}
}

View file

@ -4,6 +4,7 @@ import 'package:shuffle/core/l10n/l10n.dart';
import 'package:shuffle/core/theme/app_spacing.dart';
import 'package:shuffle/features/auth/presentation/providers/sign_in_prompt_provider.dart';
import 'package:shuffle/features/auth/presentation/widgets/sign_in_prompt.dart';
import 'package:shuffle/features/catalog/presentation/widgets/catalog_search_entry_button.dart';
/// Placeholder home screen. To be replaced by the catalog search / QUE-09.
///
@ -40,6 +41,8 @@ class HomeScreen extends ConsumerWidget {
const SizedBox(height: AppSpacing.xl),
const SignInPrompt(),
],
const SizedBox(height: AppSpacing.xl),
const CatalogSearchEntryButton(),
],
),
),

View file

@ -83,5 +83,42 @@
"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.",
"catalogSearchErrorInvalid": "Diese Suche wird nicht unterstützt. Ändere den Suchbegriff oder die Filter."
}

View file

@ -137,5 +137,79 @@
"@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", "format": "decimalPattern"}}},
"catalogSearchAnnouncement": "{count, plural, =0{No games found} =1{1 game found} other{{count} games found}}",
"@catalogSearchAnnouncement": {"description": "Screen reader announcement after a finished search", "placeholders": {"count": {"type": "int", "format": "decimalPattern"}}},
"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"},
"catalogSearchErrorInvalid": "This search is not supported. Change the search text or the filters.",
"@catalogSearchErrorInvalid": {"description": "Error state: the backend rejected the input (no retry offered)"}
}

View file

@ -601,6 +601,228 @@ 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;
/// Error state: the backend rejected the input (no retry offered)
///
/// In en, this message translates to:
/// **'This search is not supported. Change the search text or the filters.'**
String get catalogSearchErrorInvalid;
}
class _AppLocalizationsDelegate

View file

@ -305,4 +305,181 @@ 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) {
final intl.NumberFormat countNumberFormat =
intl.NumberFormat.decimalPattern(localeName);
final String countString = countNumberFormat.format(count);
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$countString Spiele',
one: '1 Spiel',
zero: 'Keine Spiele',
);
return '$_temp0';
}
@override
String catalogSearchAnnouncement(int count) {
final intl.NumberFormat countNumberFormat =
intl.NumberFormat.decimalPattern(localeName);
final String countString = countNumberFormat.format(count);
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$countString 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.';
@override
String get catalogSearchErrorInvalid =>
'Diese Suche wird nicht unterstützt. Ändere den Suchbegriff oder die Filter.';
}

View file

@ -301,4 +301,178 @@ 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) {
final intl.NumberFormat countNumberFormat =
intl.NumberFormat.decimalPattern(localeName);
final String countString = countNumberFormat.format(count);
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$countString games',
one: '1 game',
zero: 'No games',
);
return '$_temp0';
}
@override
String catalogSearchAnnouncement(int count) {
final intl.NumberFormat countNumberFormat =
intl.NumberFormat.decimalPattern(localeName);
final String countString = countNumberFormat.format(count);
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$countString 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.';
@override
String get catalogSearchErrorInvalid =>
'This search is not supported. Change the search text or the filters.';
}

View file

@ -29,4 +29,19 @@ void main() {
expect(records.last.level, Level.SEVERE);
expect(records.last.loggerName, 'Test');
});
test('QUE-04: Supabase library records below INFO are not written', () {
expect(
AppLogger.shouldWrite(LogRecord(Level.FINE, 'x', 'supabase.postgrest')),
isFalse,
);
expect(
AppLogger.shouldWrite(LogRecord(Level.WARNING, 'x', 'supabase.auth')),
isTrue,
);
expect(
AppLogger.shouldWrite(LogRecord(Level.FINE, 'x', 'CatalogRepository')),
isTrue,
);
});
}

View file

@ -0,0 +1,164 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shuffle/core/widgets/app_list_footer.dart';
import 'package:shuffle/core/widgets/app_state_message.dart';
import '../../helpers/pump_app.dart';
void main() {
group('AppStateMessage', () {
Future<void> pumpMessage(
WidgetTester tester, {
String? message,
List<AppStateAction> actions = const [],
ThemeMode themeMode = ThemeMode.light,
double? textScale,
}) => pumpApp(
tester,
themeMode: themeMode,
textScale: textScale,
home: Scaffold(
body: AppStateMessage(
icon: Icons.search_off,
title: 'Nichts gefunden',
message: message,
actions: actions,
),
),
);
testWidgets('shows title, hint and actions', (tester) async {
var pressed = 0;
await pumpMessage(
tester,
message: 'Prüfe die Schreibweise.',
actions: [
AppStateAction(
key: const Key('first'),
label: 'Erneut versuchen',
onPressed: () => pressed++,
),
AppStateAction(label: 'Zurücksetzen', onPressed: () {}),
],
);
expect(find.text('Nichts gefunden'), findsOneWidget);
expect(find.text('Prüfe die Schreibweise.'), findsOneWidget);
await tester.tap(find.byKey(const Key('first')));
expect(pressed, 1);
expect(find.text('Zurücksetzen'), findsOneWidget);
});
testWidgets('without hint and actions it is just icon and title', (
tester,
) async {
await pumpMessage(tester);
expect(find.byType(FilledButton), findsNothing);
expect(find.byType(OutlinedButton), findsNothing);
});
for (final mode in ThemeMode.values.where((m) => m != ThemeMode.system)) {
testWidgets('meets accessibility guidelines ($mode)', (tester) async {
final handle = tester.ensureSemantics();
await pumpMessage(
tester,
message: 'Hinweis',
themeMode: mode,
actions: [
AppStateAction(label: 'Erneut versuchen', onPressed: () {}),
AppStateAction(label: 'Zurücksetzen', onPressed: () {}),
],
);
await expectLater(tester, meetsGuideline(androidTapTargetGuideline));
await expectLater(tester, meetsGuideline(labeledTapTargetGuideline));
await expectLater(tester, meetsGuideline(textContrastGuideline));
handle.dispose();
});
}
testWidgets('text scale 2.0 does not overflow', (tester) async {
await pumpMessage(
tester,
message: 'Ein längerer Hinweis, der umbrechen muss.',
textScale: 2,
actions: [AppStateAction(label: 'Erneut versuchen', onPressed: () {})],
);
expect(tester.takeException(), isNull);
});
});
group('AppListFooter', () {
Future<void> pumpFooter(
WidgetTester tester, {
bool isLoading = false,
bool hasMore = false,
String? errorMessage,
VoidCallback? onLoadMore,
VoidCallback? onRetry,
}) => pumpApp(
tester,
home: Scaffold(
body: AppListFooter(
isLoading: isLoading,
hasMore: hasMore,
errorMessage: errorMessage,
loadingLabel: 'Lädt',
loadMoreLabel: 'Weitere laden',
retryLabel: 'Erneut versuchen',
onLoadMore: onLoadMore ?? () {},
onRetry: onRetry ?? () {},
),
),
settle: false,
);
testWidgets('shows the load-more button while more is available', (
tester,
) async {
var loads = 0;
await pumpFooter(tester, hasMore: true, onLoadMore: () => loads++);
await tester.tap(find.text('Weitere laden'));
expect(loads, 1);
});
testWidgets('shows a labeled indicator while loading', (tester) async {
final handle = tester.ensureSemantics();
await pumpFooter(tester, hasMore: true, isLoading: true);
expect(find.byKey(const Key('list_footer_loading')), findsOneWidget);
expect(find.text('Weitere laden'), findsNothing);
expect(find.bySemanticsLabel('Lädt'), findsOneWidget);
handle.dispose();
});
testWidgets('shows the error with retry, ahead of everything else', (
tester,
) async {
var retries = 0;
await pumpFooter(
tester,
hasMore: true,
isLoading: true,
errorMessage: 'Fehler beim Laden',
onRetry: () => retries++,
);
expect(find.text('Fehler beim Laden'), findsOneWidget);
await tester.tap(find.byKey(const Key('list_footer_retry')));
expect(retries, 1);
expect(find.byKey(const Key('list_footer_loading')), findsNothing);
});
testWidgets('shows nothing at the end of the list', (tester) async {
await pumpFooter(tester);
expect(find.byType(OutlinedButton), findsNothing);
expect(find.byKey(const Key('list_footer_loading')), findsNothing);
});
});
}

View file

@ -0,0 +1,152 @@
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 mechanicCards = '44444444-4444-4444-8444-444444444444';
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,352 @@
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/data/supabase_catalog_repository.dart';
import 'package:shuffle/features/catalog/domain/catalog_failure.dart';
import 'package:shuffle/features/catalog/domain/catalog_search_query.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../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: several genre and mechanic IDs are all sent',
() async {
await repository.search(
const CatalogSearchQuery(
filters: CatalogFilters(
genreIds: {genreStrategy, genreFamily},
mechanicIds: {mechanicDice, mechanicCards},
),
),
offset: 0,
);
final sent = body(requests.single);
expect(
sent['p_genre_ids'],
unorderedEquals([genreStrategy, genreFamily]),
);
expect(
sent['p_mechanic_ids'],
unorderedEquals([mechanicDice, mechanicCards]),
);
},
);
test('KAT-01 Kriterium 4: mechanics are sent as p_mechanic_ids', () async {
await repository.search(
const CatalogSearchQuery(
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: the repository logger never logs 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,
);
// Any other library may echo the text in its own debug logs (postgrest
// does at FINE); AppLogger drops those, so none of them is written.
for (final record in records.where(
(r) => '${r.message} ${r.error}'.contains(secret),
)) {
expect(
AppLogger.shouldWrite(record),
isFalse,
reason: '${record.loggerName} would write the search text',
);
}
final catalog = records.where((r) => r.loggerName == 'CatalogRepository');
expect(catalog, isNotEmpty);
for (final record in catalog) {
expect(record.message, isNot(contains(secret)));
expect(record.error, isNull);
expect(record.stackTrace, isNull);
}
});
});
}

View file

@ -0,0 +1,199 @@
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(
'Eingabegrenzen: the text is cut to 10 words (rest dropped silently)',
() {
expect(
CatalogSearchQuery.cleanText('a b c d e f g h i j k l'),
'a b c d e f g h i j',
);
expect(
CatalogSearchQuery.cleanText('a b c d e f g h i j'),
'a b c d e f g h i j',
);
expect(CatalogSearchQuery.cleanText('a b c'), 'a b c');
},
);
test('Eingabegrenzen: punctuation separates words like in the backend', () {
// Hyphen and colon split words: 12 words, not 6.
expect(
CatalogSearchQuery.cleanText('a-b c:d e-f g-h i-j k-l'),
'a-b c:d e-f g-h i-j',
);
expect(
CatalogSearchQuery.cleanText(
'Æon’s End: Ünïcode Wörter ß 1 2 3 4 5 6 7',
),
'Æon’s End: Ünïcode Wörter ß 1 2 3 4',
);
});
test('KAT-01 Kriterium 4: activeFilterCount counts every filter value', () {
const filters = CatalogFilters(
players: 3,
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>(),
);
for (final code in ['22003', '22P02']) {
expect(
mapCatalogError(PostgrestException(message: 'x', code: code)),
isA<CatalogInvalidInputFailure>(),
reason: code,
);
}
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,162 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shuffle/features/catalog/domain/catalog_search_query.dart';
import '../../catalog_test_support.dart';
import 'catalog_search_test_support.dart';
void main() {
group('announcements', () {
testWidgets('QUE-06 Kriterium 2: the hit count is announced once', (
tester,
) async {
final repository = FakeCatalogRepository(all: List.generate(75, game));
await pumpView(tester, repository);
tester.takeAnnouncements();
await tester.enterText(searchInput, 'spiel');
await tester.testTextInput.receiveAction(TextInputAction.search);
await tester.pump();
// Not at once: the announcement waits for a quiet moment.
expect(tester.takeAnnouncements(), isEmpty);
await tester.pump(const Duration(seconds: 1));
final messages = tester.takeAnnouncements().map((a) => a.message);
expect(messages, ['75 Spiele gefunden']);
});
testWidgets('QUE-06 Kriterium 2: nothing is announced while typing', (
tester,
) async {
final repository = FakeCatalogRepository(all: List.generate(75, game));
await pumpView(tester, repository);
await tester.pump(const Duration(seconds: 1));
tester.takeAnnouncements();
await tester.enterText(searchInput, 'sp');
await tester.pump(const Duration(milliseconds: 100));
await tester.enterText(searchInput, 'spi');
await tester.pump(const Duration(milliseconds: 100));
expect(tester.takeAnnouncements(), isEmpty);
// Let the pending search run out before the test ends.
await tester.pump(const Duration(seconds: 2));
});
testWidgets('quick filter changes give one announcement (debounced)', (
tester,
) async {
final repository = FakeCatalogRepository(all: [game(1)]);
await pumpView(tester, repository);
await tester.pump(const Duration(seconds: 1));
tester.takeAnnouncements();
await tester.tap(find.byKey(const Key('catalog_filter_button')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('catalog_term_$genreStrategy')));
await tester.pump(const Duration(milliseconds: 100));
await tester.tap(find.byKey(const Key('catalog_term_$genreFamily')));
await tester.pump(const Duration(milliseconds: 100));
await tester.tap(find.byKey(const Key('catalog_term_$mechanicDice')));
await tester.pump(const Duration(seconds: 1));
final messages = tester.takeAnnouncements().map((a) => a.message);
expect(messages, ['1 Spiel gefunden']);
});
testWidgets('an empty result is announced as well', (tester) async {
final repository = FakeCatalogRepository();
await pumpView(
tester,
repository,
initialQuery: const CatalogSearchQuery(text: 'zzz'),
);
await tester.pump(const Duration(seconds: 1));
final messages = tester.takeAnnouncements().map((a) => a.message);
expect(messages, contains('Keine Spiele gefunden'));
});
testWidgets('English announcement', (tester) async {
final repository = FakeCatalogRepository(all: List.generate(2, game));
await pumpView(tester, repository, locale: const Locale('en'));
await tester.pump(const Duration(seconds: 1));
final messages = tester.takeAnnouncements().map((a) => a.message);
expect(messages, contains('2 games found'));
});
});
group('accessibility guidelines (QUE-06 Kriterium 1, 3)', () {
Future<void> check(WidgetTester tester, {double width = 420}) async {
final handle = tester.ensureSemantics();
final repository = FakeCatalogRepository(
all: [
game(1, matchedAltTitle: 'Alternativtitel'),
game(2, publishers: ['A', 'B', 'C']),
],
);
await pumpView(
tester,
repository,
width: width,
initialQuery: const CatalogSearchQuery(text: 'spiel'),
);
await expectLater(tester, meetsGuideline(labeledTapTargetGuideline));
await expectLater(tester, meetsGuideline(androidTapTargetGuideline));
await expectLater(tester, meetsGuideline(iOSTapTargetGuideline));
await expectLater(tester, meetsGuideline(textContrastGuideline));
handle.dispose();
}
testWidgets('compact layout', check);
testWidgets('expanded layout with side bar', (tester) async {
await check(tester, width: 1200);
});
testWidgets('filter sheet', (tester) async {
final handle = tester.ensureSemantics();
final repository = FakeCatalogRepository(all: [game(1)]);
await pumpView(tester, repository);
await tester.tap(find.byKey(const Key('catalog_filter_button')));
await tester.pumpAndSettle();
await expectLater(tester, meetsGuideline(labeledTapTargetGuideline));
await expectLater(tester, meetsGuideline(androidTapTargetGuideline));
await expectLater(tester, meetsGuideline(textContrastGuideline));
handle.dispose();
});
testWidgets('empty and error states', (tester) async {
final handle = tester.ensureSemantics();
final repository = FakeCatalogRepository();
await pumpView(
tester,
repository,
initialQuery: const CatalogSearchQuery(
text: 'zzz',
filters: CatalogFilters(players: 2),
),
);
await expectLater(tester, meetsGuideline(androidTapTargetGuideline));
await expectLater(tester, meetsGuideline(labeledTapTargetGuideline));
await expectLater(tester, meetsGuideline(textContrastGuideline));
handle.dispose();
});
testWidgets('search field is labeled for screen readers', (tester) async {
final handle = tester.ensureSemantics();
final repository = FakeCatalogRepository(all: [game(1)]);
await pumpView(tester, repository);
expect(find.bySemanticsLabel('Spiele suchen'), findsWidgets);
expect(find.bySemanticsLabel('Suchbegriff löschen'), findsNothing);
handle.dispose();
});
});
}

View file

@ -0,0 +1,494 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shuffle/features/catalog/domain/catalog_failure.dart';
import 'package:shuffle/features/catalog/domain/catalog_search_query.dart';
import 'package:shuffle/features/catalog/domain/catalog_term.dart';
import '../../catalog_test_support.dart';
import 'catalog_search_test_support.dart';
Future<void> openFilterSheet(WidgetTester tester) async {
await tester.tap(find.byKey(const Key('catalog_filter_button')));
await tester.pumpAndSettle();
}
void main() {
group('filters in the bottom sheet (compact)', () {
testWidgets('KAT-01 Kriterium 4: the button shows the number of filters', (
tester,
) async {
final repository = FakeCatalogRepository(all: [game(1)]);
await pumpView(
tester,
repository,
initialQuery: const CatalogSearchQuery(
filters: CatalogFilters(players: 2, genreIds: {genreFamily}),
),
);
expect(find.text('Filter (2)'), findsOneWidget);
});
testWidgets('the button shows plain "Filter" without filters', (
tester,
) async {
final repository = FakeCatalogRepository(all: [game(1)]);
await pumpView(tester, repository);
expect(find.text('Filter'), findsOneWidget);
});
testWidgets('KAT-01 Kriterium 4: each filter kind sets its value', (
tester,
) async {
final repository = FakeCatalogRepository(all: [game(1)]);
await pumpView(tester, repository);
await openFilterSheet(tester);
Future<void> pick(String fieldKey, String label) async {
await tester.tap(find.byKey(Key(fieldKey)));
await tester.pumpAndSettle();
await tester.tap(find.text(label).last);
await tester.pumpAndSettle();
}
await pick('catalog_filter_players', '4 Spieler');
await pick('catalog_filter_duration', 'Bis 60 Min.');
await pick('catalog_filter_age', 'Ab 10 Jahren geeignet');
await tester.tap(find.byKey(const Key('catalog_term_$genreStrategy')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('catalog_term_$mechanicDice')));
await tester.pumpAndSettle();
final filters = repository.calls.last.query.filters;
expect(filters.players, 4);
expect(filters.maxMinutes, 60);
expect(filters.minAgeSuitable, 10);
expect(filters.genreIds, {genreStrategy});
expect(filters.mechanicIds, {mechanicDice});
// The list behind the sheet updates immediately.
expect(repository.calls.length, greaterThan(5));
});
testWidgets('KAT-01 Kriterium 4: two genres are both sent (AND)', (
tester,
) async {
final repository = FakeCatalogRepository(all: [game(1)]);
await pumpView(tester, repository);
await openFilterSheet(tester);
await tester.tap(find.byKey(const Key('catalog_term_$genreStrategy')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('catalog_term_$genreFamily')));
await tester.pumpAndSettle();
expect(repository.calls.last.query.filters.genreIds, {
genreStrategy,
genreFamily,
});
});
testWidgets('KAT-01 Kriterium 4: a chip can be deselected again', (
tester,
) async {
final repository = FakeCatalogRepository(all: [game(1)]);
await pumpView(tester, repository);
await openFilterSheet(tester);
await tester.tap(find.byKey(const Key('catalog_term_$genreStrategy')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('catalog_term_$genreStrategy')));
await tester.pumpAndSettle();
expect(repository.calls.last.query.filters.genreIds, isEmpty);
});
testWidgets('reset removes all filters and Done closes the sheet', (
tester,
) async {
final repository = FakeCatalogRepository(all: [game(1)]);
await pumpView(
tester,
repository,
initialQuery: const CatalogSearchQuery(
filters: CatalogFilters(players: 2, genreIds: {genreFamily}),
),
);
await openFilterSheet(tester);
await tester.tap(find.byKey(const Key('catalog_filter_reset')));
await tester.pumpAndSettle();
expect(repository.calls.last.query.hasFilters, isFalse);
await tester.tap(find.byKey(const Key('catalog_filter_done')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('catalog_filter_sheet')), findsNothing);
expect(find.text('Filter'), findsWidgets);
});
testWidgets('QUE-05 Kriterium 4: terms are named and sorted by language', (
tester,
) async {
final repository = FakeCatalogRepository(all: [game(1)]);
await pumpView(tester, repository);
await openFilterSheet(tester);
// German: Familie before Strategie.
expect(find.text('Familie'), findsOneWidget);
final de = tester.getTopLeft(find.text('Familie')).dx;
final de2 = tester.getTopLeft(find.text('Strategie')).dx;
expect(de, lessThan(de2));
});
testWidgets('terms with umlauts sort next to their base letters', (
tester,
) async {
final repository = FakeCatalogRepository(
all: [game(1)],
genres: const [
CatalogTerm(id: genreStrategy, nameDe: 'Zocken', nameEn: 'Z'),
CatalogTerm(id: genreFamily, nameDe: 'Ökonomie', nameEn: 'E'),
CatalogTerm(
id: '55555555-5555-4555-8555-555555555555',
nameDe: 'Party',
nameEn: 'P',
),
],
);
await pumpView(tester, repository);
await openFilterSheet(tester);
final party = tester.getTopLeft(find.text('Party'));
final oekonomie = tester.getTopLeft(find.text('Ökonomie'));
final zocken = tester.getTopLeft(find.text('Zocken'));
bool before(Offset a, Offset b) =>
a.dy < b.dy || (a.dy == b.dy && a.dx < b.dx);
// Ö sorts as O: Ökonomie, Party, Zocken.
expect(before(oekonomie, party), isTrue);
expect(before(party, zocken), isTrue);
});
testWidgets('QUE-05 Kriterium 4: English names and order', (tester) async {
final repository = FakeCatalogRepository(all: [game(1)]);
await pumpView(tester, repository, locale: const Locale('en'));
await openFilterSheet(tester);
expect(find.text('Dice rolling'), findsOneWidget);
expect(find.text('Strategy'), findsOneWidget);
final family = tester.getTopLeft(find.text('Family')).dx;
final strategy = tester.getTopLeft(find.text('Strategy')).dx;
expect(family, lessThan(strategy));
});
testWidgets('a vocabulary error shows a hint with retry, rest works', (
tester,
) async {
final repository = FakeCatalogRepository(all: [game(1)])
..termsFailWith = const CatalogFailure.network();
await pumpView(tester, repository);
await openFilterSheet(tester);
expect(
find.text('Die Auswahl konnte nicht geladen werden.'),
findsNWidgets(2),
);
// The other filters still work.
await tester.tap(find.byKey(const Key('catalog_filter_players')));
await tester.pumpAndSettle();
await tester.tap(find.text('2 Spieler').last);
await tester.pumpAndSettle();
expect(repository.calls.last.query.filters.players, 2);
repository.termsFailWith = null;
await tester.tap(find.byKey(const Key('catalog_terms_retry')).first);
await tester.pumpAndSettle();
expect(
find.byKey(const Key('catalog_term_$genreStrategy')),
findsOneWidget,
);
});
});
group('keyboard', () {
Future<void> tab(WidgetTester tester) async {
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
await tester.pump();
}
bool focusIsIn(Finder finder) {
final focused = FocusManager.instance.primaryFocus?.context;
return focused != null &&
find
.descendant(
of: finder,
matching: find.byElementPredicate((e) => e == focused),
)
.evaluate()
.isNotEmpty;
}
/// The tile's `Focus` sits above its keyed card.
bool focusOnTile(String id) {
final focused = FocusManager.instance.primaryFocus?.context;
return focused != null &&
find
.ancestor(
of: find.byKey(Key('catalog_tile_$id')),
matching: find.byElementPredicate((e) => e == focused),
)
.evaluate()
.isNotEmpty;
}
double scrollOffset(WidgetTester tester) => tester
.state<ScrollableState>(
find.descendant(
of: find.byKey(const Key('catalog_result_list')),
matching: find.byType(Scrollable),
),
)
.position
.pixels;
testWidgets(
'QUE-06 Kriterium 4: Tab walks field, filters, list (compact)',
(tester) async {
final repository = FakeCatalogRepository(all: List.generate(75, game));
await pumpView(tester, repository);
await tester.tap(searchInput);
await tester.pump();
await tab(tester);
expect(
focusIsIn(find.byKey(const Key('catalog_filter_button'))),
isTrue,
);
await tab(tester);
expect(focusOnTile('game-0'), isTrue);
// Walking on scrolls the list (and so loads more).
for (var i = 0; i < 12; i++) {
await tab(tester);
}
expect(scrollOffset(tester), greaterThan(0));
expect(focusOnTile('game-12'), isTrue);
},
);
testWidgets(
'QUE-06 Kriterium 4: Tab order is field, filters, list (side bar)',
(tester) async {
final repository = FakeCatalogRepository(all: List.generate(75, game));
await pumpView(tester, repository, width: 1200);
await tester.tap(searchInput);
await tester.pump();
await tab(tester);
expect(
focusIsIn(find.byKey(const Key('catalog_filter_sidebar'))),
isTrue,
reason: 'the filters come right after the search field',
);
// Through the filters (3 selects, 2 chip groups, reset) to the list.
var steps = 0;
while (!focusOnTile('game-0') && steps++ < 30) {
await tab(tester);
}
expect(focusOnTile('game-0'), isTrue);
expect(steps, lessThan(30));
},
);
testWidgets('a selection keeps keyboard focus on the select', (
tester,
) async {
final repository = FakeCatalogRepository(all: [game(1)]);
await pumpView(tester, repository);
await openFilterSheet(tester);
await tester.tap(find.byKey(const Key('catalog_filter_players')));
await tester.pumpAndSettle();
await tester.tap(find.text('4 Spieler').last);
await tester.pumpAndSettle();
expect(repository.calls.last.query.filters.players, 4);
expect(
focusIsIn(find.byKey(const Key('catalog_filter_players'))),
isTrue,
);
});
testWidgets('the sheet stays open while filters change the URL state', (
tester,
) async {
final repository = FakeCatalogRepository(all: [game(1)]);
await pumpView(tester, repository);
await openFilterSheet(tester);
await tester.tap(find.byKey(const Key('catalog_term_$genreFamily')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('catalog_filter_sheet')), findsOneWidget);
});
});
group('layout', () {
testWidgets('Plattformen Kriterium 2: side bar from 1024 dp', (
tester,
) async {
final repository = FakeCatalogRepository(all: [game(1)]);
await pumpView(tester, repository, width: 1200);
expect(find.byKey(const Key('catalog_filter_sidebar')), findsOneWidget);
expect(find.byKey(const Key('catalog_filter_button')), findsNothing);
// The filters work without a sheet.
await tester.tap(find.byKey(const Key('catalog_term_$genreFamily')));
await tester.pumpAndSettle();
expect(repository.calls.last.query.filters.genreIds, {genreFamily});
});
testWidgets('below 1024 dp there is no side bar', (tester) async {
final repository = FakeCatalogRepository(all: [game(1)]);
await pumpView(tester, repository, width: 800);
expect(find.byKey(const Key('catalog_filter_sidebar')), findsNothing);
expect(find.byKey(const Key('catalog_filter_button')), findsOneWidget);
});
for (final width in [420.0, 800.0, 1200.0]) {
testWidgets('QUE-06 Kriterium 3: text scale 2.0 at $width dp', (
tester,
) async {
final repository = FakeCatalogRepository(
all: [
game(
1,
title: 'Ein sehr langer Spieltitel für den Test',
matchedAltTitle: 'Noch ein langer alternativer Titel',
),
game(2, publishers: ['Verlag A', 'Verlag B', 'Verlag C']),
],
);
await pumpView(tester, repository, width: width, textScale: 2.0);
expect(tester.takeException(), isNull);
if (width < 1024) {
await openFilterSheet(tester);
expect(tester.takeException(), isNull);
}
});
}
});
group('paging', () {
testWidgets(
'KAT-01 Kriterium 5: scrolling to the end loads the next page',
(tester) async {
final repository = FakeCatalogRepository(all: List.generate(75, game));
await pumpView(tester, repository);
expect(repository.calls, hasLength(1));
await tester.drag(
find.byKey(const Key('catalog_result_list')),
const Offset(0, -3000),
);
await tester.pumpAndSettle();
expect(repository.calls.length, greaterThanOrEqualTo(2));
expect(repository.calls[1].offset, 30);
expect(repository.calls[1].query.isEmpty, isTrue);
},
);
testWidgets('a first page that does not fill the screen loads on', (
tester,
) async {
final repository = FakeCatalogRepository(all: List.generate(75, game));
await pumpView(tester, repository);
repository.calls.clear();
// No scroll event can come when everything is visible at once.
tester.view.physicalSize = const Size(420, 9000);
await tester.pumpAndSettle();
expect(repository.calls, isNotEmpty);
expect(repository.calls.first.offset, 30);
});
testWidgets('the end of the list shows no more button', (tester) async {
final repository = FakeCatalogRepository(all: List.generate(5, game));
await pumpView(tester, repository);
expect(repository.calls, hasLength(1));
expect(find.byKey(const Key('list_footer_load_more')), findsNothing);
expect(find.byKey(const Key('list_footer_loading')), findsNothing);
});
testWidgets(
'KAT-01 Kriterium 5: a failed load keeps the hits; retry moves focus '
'to the first new tile (QUE-06 Kriterium 4)',
(tester) async {
final repository = FakeCatalogRepository(manual: true);
await pumpView(tester, repository, settle: false);
await tester.pump();
repository.complete(0, items: List.generate(30, game), totalCount: 90);
await tester.pump();
await tester.drag(
find.byKey(const Key('catalog_result_list')),
const Offset(0, -3000),
);
await tester.pump();
expect(repository.calls, hasLength(2));
expect(find.byKey(const Key('list_footer_loading')), findsOneWidget);
repository.complete(1, failure: const CatalogFailure.network());
await tester.pumpAndSettle();
await tester.drag(
find.byKey(const Key('catalog_result_list')),
const Offset(0, -3000),
);
await tester.pumpAndSettle();
expect(
find.text('Weitere Treffer konnten nicht geladen werden.'),
findsOneWidget,
);
expect(find.byKey(const Key('catalog_tile_game-29')), findsOneWidget);
// No automatic retry loop.
expect(repository.calls, hasLength(2));
await tester.tap(find.byKey(const Key('list_footer_retry')));
await tester.pump();
expect(repository.calls, hasLength(3));
expect(repository.calls[2].offset, 30);
repository.complete(
2,
items: List.generate(30, (i) => game(30 + i)),
totalCount: 90,
);
await tester.pumpAndSettle();
final tile = find.byKey(const Key('catalog_tile_game-30'));
expect(tile, findsOneWidget);
expect(
find.ancestor(
of: tile,
matching: find.byWidgetPredicate(
(w) => w is Focus && (w.focusNode?.hasPrimaryFocus ?? false),
),
),
findsOneWidget,
);
},
);
});
}

View file

@ -0,0 +1,425 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shuffle/features/catalog/domain/catalog_failure.dart';
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();
unawaited(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();
unawaited(controller().loadMore());
await tester.pump();
expect(state().items, hasLength(60));
expect(repository.calls.last.offset, 30);
expect(state().hasMore, isTrue);
unawaited(controller().loadMore());
await tester.pump();
expect(state().items, hasLength(75));
expect(state().hasMore, isFalse);
// Nothing more to load.
unawaited(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();
unawaited(controller().loadMore());
unawaited(controller().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();
unawaited(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();
unawaited(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();
unawaited(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: a stale loadMore answer is not appended', (
tester,
) async {
final repo = FakeCatalogRepository(manual: true);
useRepository(repo);
controller().init(const CatalogSearchQuery());
await tester.pump();
repo.complete(0, items: [game(1)], totalCount: 3);
await tester.pump();
unawaited(controller().loadMore());
await tester.pump();
// A new search starts while the next page is still on its way ...
controller().submitText('neu');
await tester.pump();
// ... and the old page arrives before the new answer.
repo.complete(1, items: [game(2), game(3)], totalCount: 3);
await tester.pump();
expect(state().items.map((g) => g.id), ['game-1']);
repo.complete(2, items: [game(9, title: 'Neu')], totalCount: 1);
await tester.pump();
expect(state().items.map((g) => g.title), ['Neu']);
});
run('KAT-01 Kriterium 5: an error while loading more keeps the hits', (
tester,
) async {
controller().init(const CatalogSearchQuery());
await tester.pump();
repository.failWith = const CatalogFailure.network();
unawaited(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;
unawaited(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;
unawaited(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);
unawaited(controller().loadMore());
await tester.pump();
expect(state().searchGeneration, 1);
controller().submitText('abc');
await tester.pump();
expect(state().searchGeneration, 2);
});
}

View file

@ -0,0 +1,320 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
import 'package:shuffle/core/routing/app_router.dart';
import 'package:shuffle/core/routing/url_replacer.dart';
import 'package:shuffle/features/auth/domain/auth_status.dart';
import 'package:shuffle/features/auth/domain/auth_user.dart';
import 'package:shuffle/features/auth/presentation/providers/auth_redirect.dart';
import 'package:shuffle/features/catalog/domain/catalog_failure.dart';
import 'package:shuffle/features/catalog/presentation/catalog_routes.dart';
import 'package:shuffle/features/catalog/presentation/search/catalog_search_view.dart';
import '../../../../helpers/pump_app.dart';
import '../../../../helpers/router_helpers.dart';
import '../../catalog_test_support.dart';
import 'catalog_search_test_support.dart';
void main() {
group('route /catalog', () {
testWidgets('Plattformen: /catalog is public (no sign-in redirect)', (
tester,
) async {
expect(
authRedirectFor(
const SignedOut(),
Uri.parse('/catalog?q=catan'),
privatePaths: const [],
),
isNull,
);
expect(
authRedirectFor(
const SignedIn(
AuthUser(id: 'u1', email: 'a@example.com', emailConfirmed: true),
),
Uri.parse('/catalog'),
),
isNull,
);
});
testWidgets('KAT-01 Kriterium 1: the URL parameters are the start values', (
tester,
) async {
useWindow(tester, 420);
final repository = FakeCatalogRepository(all: [game(1)]);
final container = await pumpShuffleApp(
tester,
overrides: catalogOverrides(repository),
);
container
.read(appRouterProvider)
.go('/catalog?q=catan&players=3&maxMinutes=60&genre=$genreStrategy');
await tester.pumpAndSettle();
expect(find.byKey(const Key('catalog_search_screen')), findsOneWidget);
final query = repository.calls.first.query;
expect(query.text, 'catan');
expect(query.filters.players, 3);
expect(query.filters.maxMinutes, 60);
expect(query.filters.genreIds, {genreStrategy});
});
testWidgets('invalid URL parameters are ignored, the page still opens', (
tester,
) async {
useWindow(tester, 420);
final repository = FakeCatalogRepository(all: [game(1)]);
final container = await pumpShuffleApp(
tester,
overrides: catalogOverrides(repository),
);
container
.read(appRouterProvider)
.go('/catalog?players=99&genre=xyz&maxMinutes=abc');
await tester.pumpAndSettle();
expect(find.byKey(const Key('catalog_search_screen')), findsOneWidget);
expect(repository.calls.first.query.isEmpty, isTrue);
});
testWidgets('Android: system back from a deep link returns to the start '
'page instead of closing the app', (tester) async {
useWindow(tester, 420);
final repository = FakeCatalogRepository(all: [game(1)]);
final container = await pumpShuffleApp(
tester,
overrides: catalogOverrides(repository),
);
container.read(appRouterProvider).go('/catalog?q=catan');
await tester.pumpAndSettle();
expect(find.byKey(const Key('catalog_search_screen')), findsOneWidget);
// The framework's system back; `false` would mean the app closes.
final handled = await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect(handled, isTrue);
expect(find.byKey(const Key('home_screen')), findsOneWidget);
});
testWidgets('start page entry opens the search and back returns', (
tester,
) async {
useWindow(tester, 420);
final repository = FakeCatalogRepository(all: [game(1)]);
await pumpShuffleApp(tester, overrides: catalogOverrides(repository));
expect(find.text('Search games'), findsOneWidget);
expect(
tester
.getSize(find.byKey(const Key('home_catalog_search_button')))
.height,
greaterThanOrEqualTo(48),
);
await tester.tap(find.byKey(const Key('home_catalog_search_button')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('catalog_search_screen')), findsOneWidget);
expect(find.text('Spiel 1'), findsOneWidget);
await tester.tap(find.byType(BackButton));
await tester.pumpAndSettle();
expect(find.byKey(const Key('home_screen')), findsOneWidget);
});
});
group('URL sync (web)', () {
late List<MethodCall> platformCalls;
setUp(() => platformCalls = []);
Future<GoRouter> pumpRouted(
WidgetTester tester,
FakeCatalogRepository repository, {
required bool isWeb,
String initialLocation = '/catalog?q=alt',
}) async {
useWindow(tester, 420);
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
SystemChannels.navigation,
(call) async {
platformCalls.add(call);
return null;
},
);
addTearDown(
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
SystemChannels.navigation,
null,
),
);
final router = buildTestRouter(
routes: [
GoRoute(
path: '/',
builder: (context, state) => const SizedBox(),
routes: catalogRoutes,
),
],
initialLocation: initialLocation,
);
addTearDown(router.dispose);
await pumpApp(
tester,
router: router,
overrides: [
...catalogOverrides(repository),
urlReplacerProvider.overrideWithValue(UrlReplacer(isWeb: isWeb)),
],
settle: false,
);
await tester.pumpAndSettle();
platformCalls.clear();
return router;
}
testWidgets('URL is replaced after a search without rebuilding the page', (
tester,
) async {
final repository = FakeCatalogRepository(all: [game(1)]);
final router = await pumpRouted(tester, repository, isWeb: true);
final stateBefore = tester.state(find.byType(CatalogSearchView));
final callsBefore = repository.calls.length;
await tester.enterText(searchInput, 'catan');
await tester.testTextInput.receiveAction(TextInputAction.search);
await tester.pumpAndSettle();
// Router and browser address agree on the new search.
expect(currentUri(router).queryParameters['q'], 'catan');
final updates = platformCalls.where(
(c) => c.method == 'routeInformationUpdated',
);
expect(updates, isNotEmpty);
final last = updates.last.arguments as Map<Object?, Object?>;
expect(last['uri'].toString(), '/catalog?q=catan');
expect(last['replace'], isTrue);
// No history entry from the router for this change.
expect(
updates.where((c) => (c.arguments as Map)['replace'] != true),
isEmpty,
);
// Same page state: nothing rebuilt, field and focus intact.
expect(tester.state(find.byType(CatalogSearchView)), same(stateBefore));
expect(tester.widget<TextField>(searchInput).controller!.text, 'catan');
// Exactly one more search (no re-init after the URL change).
expect(repository.calls.length, callsBefore + 1);
});
testWidgets('filters are part of the written URL', (tester) async {
final repository = FakeCatalogRepository(all: [game(1)]);
final router = await pumpRouted(tester, repository, isWeb: true);
await tester.tap(find.byKey(const Key('catalog_filter_button')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('catalog_term_$genreFamily')));
await tester.pumpAndSettle();
final params = currentUri(router).queryParameters;
expect(params['q'], 'alt');
expect(params['genre'], genreFamily);
// Writing the URL does not close the open filter sheet.
expect(find.byKey(const Key('catalog_filter_sheet')), findsOneWidget);
});
const unknownGenre = '99999999-9999-4999-8999-999999999999';
const unknownMechanic = '88888888-8888-4888-8888-888888888888';
testWidgets('unknown IDs from the URL are removed once the vocabulary is '
'loaded; valid ones stay, the search runs once more', (tester) async {
final repository = FakeCatalogRepository(all: [game(1)]);
final router = await pumpRouted(
tester,
repository,
isWeb: true,
initialLocation:
'/catalog?genre=$genreStrategy,$unknownGenre'
'&mechanic=$unknownMechanic,$mechanicDice',
);
final last = repository.calls.last.query.filters;
expect(last.genreIds, {genreStrategy});
expect(last.mechanicIds, {mechanicDice});
// First search with the raw URL values, then the cleaned one(s); no loop.
expect(repository.calls.length, lessThanOrEqualTo(3));
final before = repository.calls.length;
await tester.pump(const Duration(seconds: 2));
expect(repository.calls.length, before);
// The counter shows what is really active and the URL follows.
expect(find.text('Filter (2)'), findsOneWidget);
final params = currentUri(router).queryParameters;
expect(params['genre'], genreStrategy);
expect(params['mechanic'], mechanicDice);
});
testWidgets('a single unknown ID triggers exactly one extra search', (
tester,
) async {
final repository = FakeCatalogRepository(all: [game(1)]);
await pumpRouted(
tester,
repository,
isWeb: true,
initialLocation: '/catalog?genre=$genreStrategy,$unknownGenre',
);
expect(repository.calls, hasLength(2));
expect(repository.calls.last.query.filters.genreIds, {genreStrategy});
});
testWidgets('only known IDs: nothing is removed, no extra search', (
tester,
) async {
final repository = FakeCatalogRepository(all: [game(1)]);
await pumpRouted(
tester,
repository,
isWeb: true,
initialLocation: '/catalog?genre=$genreStrategy',
);
expect(repository.calls, hasLength(1));
});
testWidgets('a vocabulary load error removes nothing', (tester) async {
final repository = FakeCatalogRepository(all: [game(1)])
..termsFailWith = const CatalogFailure.network();
await pumpRouted(
tester,
repository,
isWeb: true,
initialLocation: '/catalog?genre=$genreStrategy,$unknownGenre',
);
expect(repository.calls, hasLength(1));
expect(repository.calls.last.query.filters.genreIds, {
genreStrategy,
unknownGenre,
});
});
testWidgets('outside the web nothing is written', (tester) async {
final repository = FakeCatalogRepository(all: [game(1)]);
final router = await pumpRouted(tester, repository, isWeb: false);
await tester.enterText(searchInput, 'catan');
await tester.testTextInput.receiveAction(TextInputAction.search);
await tester.pumpAndSettle();
expect(
platformCalls.where((c) => c.method == 'routeInformationUpdated'),
isEmpty,
);
expect(currentUri(router).queryParameters['q'], 'alt');
});
});
}

View file

@ -0,0 +1,48 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shuffle/features/catalog/domain/catalog_search_query.dart';
import 'package:shuffle/features/catalog/presentation/search/catalog_search_view.dart';
import '../../../../helpers/pump_app.dart';
import '../../catalog_test_support.dart';
/// Sets a logical window size for the test (device pixel ratio 1).
void useWindow(WidgetTester tester, double width, [double height = 900]) {
tester.view.physicalSize = Size(width, height);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
}
Future<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),
);

View file

@ -0,0 +1,394 @@
import 'package:flutter/material.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 '../../catalog_test_support.dart';
import 'catalog_search_test_support.dart';
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('rejected input shows an own text and offers no retry', (
tester,
) async {
final repository = FakeCatalogRepository()
..failWith = const CatalogFailure.invalidInput();
await pumpView(tester, repository);
expect(
find.text(
'Diese Suche wird nicht unterstützt. Ändere den Suchbegriff oder die Filter.',
),
findsOneWidget,
);
expect(find.byKey(const Key('catalog_error_retry')), findsNothing);
});
testWidgets('Zustände Kriterium 2: other errors show the generic text', (
tester,
) async {
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);
});
});
}

View file

@ -79,4 +79,22 @@ getestet; Tests liegen in `app/test/core/widgets/` und dienen als lauffähige Vo
| `AppBanner` | `type` (`AppBannerType.info` / `error`), `message`, `typeLabel` (gesprochenes Präfix, z. B. „Fehler“), optional `actionLabel` + `onAction` (48-dp-TextButton), `actionEnabled` (Standard `true`; `false` lässt die Aktion sichtbar, aber deaktiviert, z. B. während eine Anfrage läuft; Semantics melden sie als nicht aktiviert, der Screenreader-Fokus geht nicht verloren). Live-Region mit „typeLabel: message“. |
| `AppFieldError` | Interner Baustein: Fehlerzeile mit Icon als Live-Region, von Textfeld und Checkbox genutzt. |
## Listen-Komponenten (`app/lib/core/widgets/`, #3 KAT-01)
Für jede Liste in R1 (Zustände Krit. 1–3): Leer- und Fehlerzustand sowie Listenfuß zum Nachladen. Gleiche Regeln wie bei den
Formular-Komponenten (Texte von außen, Tests gegen die Guidelines in Hell und Dunkel, Umbruch statt Abschneiden bei 200 %).
| Komponente | Zweck und API (Kurzform) |
|---|---|
| `AppStateMessage` | Leer-/Fehlerzustand: `icon` (dekorativ, für Screenreader ausgeblendet), `title`, optional `message`, `actions` (`AppStateAction(label, onPressed, key)`; die erste Aktion ist tonal hervorgehoben, weitere umrandet; alle 48 dp). Titel und Hinweis sind eine Live-Region. |
| `AppListFooter` | Fuß einer seitenweise geladenen Liste: `isLoading` (beschrifteter Fortschrittskreis), `hasMore` (Schaltfläche „Weitere laden“, per Tastatur und Screenreader erreichbar), `errorMessage` + Wiederholen (bereits geladene Einträge bleiben). Vorrang: Fehler vor Laden vor „Weitere laden“. Automatisches Nachladen ist Sache des Aufrufers; die Schaltfläche bleibt der Weg für Tastatur und Screenreader. |
**Katalogsuche (`features/catalog/presentation/search/`)** – Aufbau und Verhalten, Entscheidungen der Umsetzung (Spec #3 § 5.6):
- Kompakt (< 600 dp) und mittel (600–1023 dp): Suchfeld, darunter die Schaltfläche „Filter (n)“ mit Bottom Sheet (Änderungen wirken sofort, „Fertig“ schließt); ab 1024 dp feste Seitenleiste (320 dp) links, Trefferliste rechts mit höchstens 720 dp Breite.
- Einfachauswahl (Spielerzahl, Dauer, Alter) als beschriftete Auswahlfelder mit „Beliebig“; Genre und Mechanik als Filter-Chips, nach lokalisiertem Namen sortiert. Fehler beim Laden der Begriffe betrifft nur diese beiden Bereiche („Erneut versuchen“).
- Trefferkachel: `GameCover` klein (Platzhalter), Titel (2 Zeilen), „auch: …“, Jahr · Verlage (zwei, sonst „+n“), Kurzangaben; ein zusammengefasstes Semantics-Label. Ohne `onTap` ist die Kachel kein Button, aber ein Tab-Stopp mit Fokusring (3 dp): Tastaturnutzer durchlaufen so die Liste, was sie scrollt und das Nachladen auslöst (die Liste wird lazy gebaut). Im Screenreader-Durchgang bewerten, ob die vielen Tab-Stopps stören.
- Trefferzahl über der Liste; Ansage der Trefferzahl 600 ms nach Abschluss der letzten Suche (`SemanticsService.sendAnnouncement`), nie während des Tippens.
- Nach „Weitere laden“ (bzw. „Erneut versuchen“ im Listenfuß) springt der Fokus auf die erste neu geladene Kachel. Automatisches Nachladen, wenn weniger als etwa fünf Kacheln unterhalb des sichtbaren Bereichs liegen; nach einem Fehler nur noch über die Schaltfläche (keine Wiederholungsschleife).
Auth-Screens: Layout-Skizzen und Textvorschläge siehe `docs/design/auth-screens.md`.