feat(catalog): Katalogsuche in der App (KAT-01, 3b App) #63
9 changed files with 240 additions and 107 deletions
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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', () {
|
||||
|
|
|
|||
|
|
@ -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', () {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
|
|
@ -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', () {
|
||||
|
|
|
|||
Loading…
Reference in a new issue