feat(catalog): Katalogsuche in der App (KAT-01, 3b App) #63

Merged
cschlaefke merged 9 commits from feature/3b-katalog-suche-app into main 2026-11-04 17:29:11 +00:00
9 changed files with 240 additions and 107 deletions
Showing only changes of commit 2d18a49fc2 - Show all commits

View file

@ -101,13 +101,16 @@ class SupabaseCatalogRepository implements CatalogRepository {
}
}
/// Maps and logs an error. Logs the failure kind and the error type only.
/// Maps and logs an error. Logs the failure kind only.
CatalogFailure _fail(String operation, Object error) {
final failure = mapCatalogError(error);
_log.warning(
'Catalog $operation failed: ${failure.runtimeType} '
'(${error.runtimeType})',
);
// Stable names (runtime type names are minified on the web).
final kind = switch (failure) {
CatalogNetworkFailure() => 'network',
CatalogInvalidInputFailure() => 'invalidInput',
CatalogUnexpectedFailure() => 'unexpected',
};
_log.warning('Catalog $operation failed: $kind');
return failure;
}
}

View file

@ -263,37 +263,52 @@ class _CatalogSearchViewState extends ConsumerState<CatalogSearchView> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: EdgeInsets.fromLTRB(
padding.left,
padding.top,
padding.right,
AppSpacing.sm,
FocusTraversalOrder(
order: const NumericFocusOrder(1),
child: Padding(
padding: EdgeInsets.fromLTRB(
padding.left,
padding.top,
padding.right,
AppSpacing.sm,
),
child: _buildHeader(state, showFilterButton: !expanded),
),
),
Expanded(
child: FocusTraversalOrder(
order: const NumericFocusOrder(3),
child: _buildResults(state, padding),
),
child: _buildHeader(state, showFilterButton: !expanded),
),
Expanded(child: _buildResults(state, padding)),
],
),
),
);
if (!expanded) {
return main;
}
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: _sidebarWidth,
child: SingleChildScrollView(
key: const Key('catalog_filter_sidebar'),
padding: EdgeInsets.all(padding.left),
child: _buildSidebar(state),
),
),
const VerticalDivider(width: 1),
Expanded(child: main),
],
// Tab order (Spec #3 § 6): search field, filters, list. With the side
// bar left of the field the visual order alone would be wrong.
return FocusTraversalGroup(
policy: OrderedTraversalPolicy(),
child: expanded
? Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
FocusTraversalOrder(
order: const NumericFocusOrder(2),
child: SizedBox(
width: _sidebarWidth,
child: SingleChildScrollView(
key: const Key('catalog_filter_sidebar'),
padding: EdgeInsets.all(padding.left),
child: _buildSidebar(state),
),
),
),
const VerticalDivider(width: 1),
Expanded(child: main),
],
)
: main,
);
},
);

View file

@ -118,24 +118,29 @@ class _SingleChoice extends StatelessWidget {
@override
Widget build(BuildContext context) {
// A controlled drop-down (no form field that keeps its own value), so a
// selection does not rebuild the field and keyboard focus stays on it.
return KeyedSubtree(
key: fieldKey,
child: DropdownButtonFormField<int?>(
key: ValueKey(value),
// The field keeps its own value; a new key follows outside changes
// (reset, URL).
initialValue: value,
isExpanded: true,
child: InputDecorator(
// "Any" is a value too, so the label always floats.
isEmpty: false,
decoration: InputDecoration(labelText: label),
items: [
DropdownMenuItem<int?>(child: Text(anyLabel)),
for (final option in options)
DropdownMenuItem<int?>(
value: option,
child: Text(optionLabel(option)),
),
],
onChanged: onChanged,
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,
),
),
),
);
}

View file

@ -8,9 +8,10 @@ import 'package:shuffle/features/catalog/domain/catalog_game_summary.dart';
///
/// Shows a [GameCover] placeholder (no image URL in #3), title, year,
/// publishers and short facts; missing facts are left out. [onTap] is `null`
/// in #3: the tile is then not a button (no dead link) but still focusable
/// programmatically ([focusNode]) so keyboard focus can move to the first
/// newly loaded tile.
/// in #3: the tile is then not a button (no dead link) but stays a keyboard
/// stop with a visible focus ring, so keyboard users can walk the list (which
/// scrolls it and loads more) and focus can move to the first newly loaded
/// tile ([focusNode]).
class CatalogGameTile extends StatefulWidget {
const CatalogGameTile({
required this.game,
@ -147,8 +148,6 @@ class _CatalogGameTileState extends State<CatalogGameTile> {
final onTap = widget.onTap;
return Focus(
focusNode: widget.focusNode,
// Not a tab stop while the tile does nothing, but focusable by code.
skipTraversal: onTap == null,
onFocusChange: (focused) => setState(() => _focused = focused),
child: Semantics(
container: true,

View file

@ -3,7 +3,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:shuffle/features/catalog/domain/catalog_search_query.dart';
import '../../catalog_test_support.dart';
import 'catalog_search_view_test.dart';
import 'catalog_search_test_support.dart';
void main() {
group('announcements', () {

View file

@ -5,7 +5,7 @@ import 'package:shuffle/features/catalog/domain/catalog_failure.dart';
import 'package:shuffle/features/catalog/domain/catalog_search_query.dart';
import '../../catalog_test_support.dart';
import 'catalog_search_view_test.dart';
import 'catalog_search_test_support.dart';
Future<void> openFilterSheet(WidgetTester tester) async {
await tester.tap(find.byKey(const Key('catalog_filter_button')));
@ -184,25 +184,128 @@ void main() {
});
group('keyboard', () {
testWidgets('QUE-06 Kriterium 4: Tab leads from the field to the filters', (
Future<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(searchInput);
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
await tester.pump();
final focused = FocusManager.instance.primaryFocus!.context;
await tester.tap(find.byKey(const Key('catalog_filter_players')));
await tester.pumpAndSettle();
await tester.tap(find.text('4 Spieler').last);
await tester.pumpAndSettle();
expect(repository.calls.last.query.filters.players, 4);
expect(
find.descendant(
of: find.byKey(const Key('catalog_filter_button')),
matching: find.byElementPredicate((e) => e == focused),
),
findsOneWidget,
focusIsIn(find.byKey(const Key('catalog_filter_players'))),
isTrue,
);
});
testWidgets('the sheet stays open while filters change the URL state', (
tester,
) async {
final repository = FakeCatalogRepository(all: [game(1)]);
await pumpView(tester, repository);
await openFilterSheet(tester);
await tester.tap(find.byKey(const Key('catalog_term_$genreFamily')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('catalog_filter_sheet')), findsOneWidget);
});
});
group('layout', () {

View file

@ -13,7 +13,7 @@ import 'package:shuffle/features/catalog/presentation/search/catalog_search_view
import '../../../../helpers/pump_app.dart';
import '../../../../helpers/router_helpers.dart';
import '../../catalog_test_support.dart';
import 'catalog_search_view_test.dart';
import 'catalog_search_test_support.dart';
void main() {
group('route /catalog', () {
@ -187,6 +187,8 @@ void main() {
final params = currentUri(router).queryParameters;
expect(params['q'], 'alt');
expect(params['genre'], genreFamily);
// Writing the URL does not close the open filter sheet.
expect(find.byKey(const Key('catalog_filter_sheet')), findsOneWidget);
});
testWidgets('outside the web nothing is written', (tester) async {

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

@ -4,51 +4,9 @@ import 'package:shuffle/core/widgets/game_cover.dart';
import 'package:shuffle/features/catalog/domain/catalog_failure.dart';
import 'package:shuffle/features/catalog/domain/catalog_search_query.dart';
import 'package:shuffle/features/catalog/presentation/search/catalog_search_controller.dart';
import 'package:shuffle/features/catalog/presentation/search/catalog_search_view.dart';
import '../../../../helpers/pump_app.dart';
import '../../catalog_test_support.dart';
/// Sets a logical window size for the test (device pixel ratio 1).
void useWindow(WidgetTester tester, double width, [double height = 900]) {
tester.view.physicalSize = Size(width, height);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
}
Future<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),
);
import 'catalog_search_test_support.dart';
void main() {
group('list and tiles', () {