Keep search controls and filters visible with a dismiss button

Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
kenny lopez
2026-10-15 08:21:53 -04:00
parent 8e3fb0c312
commit 8ba536060d
2 changed files with 193 additions and 652 deletions

View File

@@ -12,8 +12,8 @@ import '../../shared/widgets/avatar_image.dart';
import '../../shared/widgets/buzz_loading_indicator.dart';
import '../../shared/widgets/buzz_search_field.dart';
import '../../shared/widgets/filter_chip_bar.dart';
import '../../shared/widgets/frosted_app_bar.dart';
import '../../shared/widgets/frosted_scaffold.dart';
import '../../shared/widgets/concentric_sheet_surface.dart';
import '../../shared/widgets/ios_glass_navigation_button.dart';
import '../../shared/widgets/message_author_meta.dart';
import '../channels/channel.dart';
import '../channels/channel_detail_page.dart';
@@ -34,34 +34,12 @@ part 'search_page/message_tile.dart';
enum _SearchFilter { all, messages, channels, people }
const _searchIdleFieldHeight = 45.0;
const _searchIdleFieldHeight = 48.0;
const _searchIdleTextSize = 15.0;
const _searchFieldVerticalPadding = Grid.xxs;
const _searchFieldMoveDuration = Duration(milliseconds: 160);
const _searchCancelEnterDuration = Duration(milliseconds: 80);
const _searchIdleFieldTopInset = Grid.half;
const _searchControlsToFiltersGap = Grid.xxs;
const _searchFilterChipVerticalPadding = Grid.xxs;
const _searchFilterBarVerticalPadding = Grid.xxs;
const _searchHeaderFiltersMinHeight = Grid.xl;
const _searchActiveFieldRightInsetMin = 72.0;
/// Reserves the Cancel action's scaled label, padding, and app-bar edge inset.
double _searchActiveFieldRightInset(BuildContext context) {
final textPainter = TextPainter(
text: TextSpan(
text: 'Cancel',
style: filterChipTextStyle.copyWith(fontWeight: FontWeight.w500),
),
textScaler: MediaQuery.textScalerOf(context),
textDirection: Directionality.of(context),
)..layout();
final cancelWidth =
textPainter.width + Grid.half * 2 + Grid.gutter + Grid.xxs;
return cancelWidth > _searchActiveFieldRightInsetMin
? cancelWidth
: _searchActiveFieldRightInsetMin;
}
double _idleSearchFieldHeight(BuildContext context) {
final scaledFontSize = MediaQuery.textScalerOf(
@@ -101,80 +79,36 @@ class SearchPage extends HookConsumerWidget {
.whenData((value) => value?.pubkey)
.value;
final activeFilter = useState(_SearchFilter.all);
final textController = useTextEditingController();
final textController = useTextEditingController(text: searchState.query);
final focusNode = useFocusNode();
final isSearchEditing = useState(false);
final isTabActivationInFlight = useRef(false);
final reduceMotion = MediaQuery.disableAnimationsOf(context);
final nativeIos = defaultTargetPlatform == TargetPlatform.iOS;
final searchSurfaceColor = navigationSearchSurface(context);
final searchPrimaryColor = navigationPrimaryForeground(context);
final searchPlaceholderColor = navigationSecondaryForeground(context);
final headerTitleStyle = context.textTheme.titleMedium?.copyWith(
fontSize: 22,
fontWeight: FontWeight.w600,
);
final idleSearchFieldHeight = _idleSearchFieldHeight(context);
final searchHeaderFiltersHeight = _searchHeaderFiltersHeight(context);
final searchActiveFieldRightInset = _searchActiveFieldRightInset(context);
final nativeIos = defaultTargetPlatform == TargetPlatform.iOS;
const activeFieldTop = _searchIdleFieldTopInset;
final filtersTop =
activeFieldTop + idleSearchFieldHeight + _searchControlsToFiltersGap;
final searchControlHeight = idleSearchFieldHeight > Grid.xl
? idleSearchFieldHeight
: Grid.xl;
final searchHeaderBottomHeight = isSearchEditing.value
? filtersTop + searchHeaderFiltersHeight + _searchControlsToFiltersGap
: idleSearchFieldHeight + _searchIdleFieldTopInset + Grid.xxs;
final topSectionHeight = frostedAppBarHeight(
context,
titleStyle: headerTitleStyle,
bottomHeight: searchHeaderBottomHeight,
);
final fieldHeight = _idleSearchFieldHeight(context);
final filtersHeight = _searchHeaderFiltersHeight(context);
void activateSearch() {
isSearchEditing.value = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (context.mounted && isSearchEditing.value) {
focusNode.requestFocus();
}
});
}
void deactivateSearch() {
if (!isSearchEditing.value) return;
isSearchEditing.value = false;
}
void activateSearch() => focusNode.requestFocus();
useEffect(() {
final tabReselection = this.tabReselection;
if (tabReselection == null) return null;
final reselection = tabReselection;
if (reselection == null) return null;
void reactivateSearch() {
// The same tab gesture can report focus loss after this callback. Keep
// that notification from starting a competing return animation while
// the normal field activation path restores focus.
isTabActivationInFlight.value = true;
activateSearch();
focusNode.requestFocus();
WidgetsBinding.instance.addPostFrameCallback((_) {
isTabActivationInFlight.value = false;
if (context.mounted) focusNode.requestFocus();
});
WidgetsBinding.instance.ensureVisualUpdate();
}
tabReselection.addListener(reactivateSearch);
return () => tabReselection.removeListener(reactivateSearch);
reselection.addListener(reactivateSearch);
return () => reselection.removeListener(reactivateSearch);
}, [tabReselection, focusNode]);
useEffect(() {
void resetIdlePromptWhenFocusLeaves() {
if (!focusNode.hasFocus && !isTabActivationInFlight.value) {
deactivateSearch();
}
}
focusNode.addListener(resetIdlePromptWhenFocusLeaves);
return () => focusNode.removeListener(resetIdlePromptWhenFocusLeaves);
}, [focusNode]);
void closeSearch() {
focusNode.unfocus();
Navigator.of(context).maybePop();
}
void runRecentSearch(String query) {
textController.value = TextEditingValue(
@@ -186,179 +120,118 @@ class SearchPage extends HookConsumerWidget {
ref.read(searchProvider.notifier).search(query);
}
return FrostedScaffold(
nativePinnedBody: true,
return Scaffold(
backgroundColor: context.colors.surface,
// Keep the empty state centered in the page rather than the portion left
// above the keyboard.
resizeToAvoidBottomInset: false,
appBar: FrostedAppBar(
nativeTitle: 'Search',
nativeLayoutDuration: Duration.zero,
nativeLargeTitle: true,
nativeActions: const [],
automaticallyImplyLeading: true,
horizontalInset: Grid.twelve,
showBottomDivider: true,
bottomDividerOpacity: 0.07,
titleStyle: headerTitleStyle,
title: Text(
'Search',
key: const ValueKey('search-header-title'),
style: headerTitleStyle,
),
actions: const [],
bottomHeight: searchHeaderBottomHeight,
bottomOverlap: 0,
bottom: Stack(
clipBehavior: Clip.none,
body: SafeArea(
bottom: false,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
AnimatedPositioned(
duration: Duration.zero,
curve: Curves.easeInOutCubic,
left: Grid.gutter,
right: isSearchEditing.value
? searchActiveFieldRightInset
: Grid.gutter,
top: activeFieldTop,
height: idleSearchFieldHeight,
// Do not key this subtree by the visual state: replacing the
// TextField immediately after its first tap can detach its
// native input connection before the keyboard is shown.
child: SizedBox(
key: const Key('search-field-container'),
child: BuzzSearchField(
controller: textController,
focusNode: focusNode,
hintText: 'Search messages, channels, and people',
iconColor: searchPrimaryColor,
inputColor: searchPrimaryColor,
placeholderColor: searchPlaceholderColor,
surfaceColor: searchSurfaceColor,
isEditing: isSearchEditing.value,
reduceMotion: reduceMotion,
motionDuration: _searchFieldMoveDuration,
onTap: activateSearch,
onChanged: (value) =>
ref.read(searchProvider.notifier).search(value),
onSubmitted: (value) {
final query = value.trim();
if (query.isEmpty) return;
ref.read(recentSearchesProvider.notifier).record(query);
},
),
Padding(
padding: const EdgeInsets.fromLTRB(
Grid.gutter,
Grid.half,
Grid.gutter,
Grid.xxs,
),
),
if (isSearchEditing.value)
Positioned(
top: activeFieldTop,
right: Grid.gutter,
width: searchActiveFieldRightInset - Grid.gutter - Grid.xxs,
height: searchControlHeight,
child: Semantics(
button: true,
label: 'Cancel search',
child: GestureDetector(
key: Key(nativeIos ? 'search-ios-cancel' : 'search-cancel'),
behavior: HitTestBehavior.opaque,
onTap: () {
textController.clear();
ref.read(searchProvider.notifier).clear();
deactivateSearch();
focusNode.unfocus();
},
child: Align(
alignment: Alignment.topRight,
child: SizedBox(
height: idleSearchFieldHeight,
child: Align(
alignment: Alignment.centerRight,
child: Text(
'Cancel',
style: filterChipTextStyle.copyWith(
color: context.colors.primary,
fontWeight: FontWeight.w500,
),
),
child: Row(
children: [
Expanded(
child: SizedBox(
key: const Key('search-field-container'),
height: fieldHeight,
child: ConcentricSheetSurface(
enabled: nativeIos,
usesGlass: true,
providesSheetSurface: false,
padding: EdgeInsets.zero,
color: searchSurfaceColor,
minimumRadius: fieldHeight / 2,
contentClipRadius: fieldHeight / 2,
child: BuzzSearchField(
controller: textController,
focusNode: focusNode,
hintText: 'Search messages, channels, and people',
iconColor: searchPrimaryColor,
inputColor: searchPrimaryColor,
placeholderColor: searchPlaceholderColor,
surfaceColor: nativeIos
? Colors.transparent
: searchSurfaceColor,
isEditing: false,
reduceMotion: true,
motionDuration: Duration.zero,
onTap: activateSearch,
onChanged: (value) =>
ref.read(searchProvider.notifier).search(value),
onSubmitted: (value) {
final query = value.trim();
if (query.isNotEmpty) {
ref
.read(recentSearchesProvider.notifier)
.record(query);
}
},
),
),
),
),
),
),
Positioned.fill(
child: AnimatedSwitcher(
duration: reduceMotion
? Duration.zero
: _searchCancelEnterDuration,
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeInCubic,
transitionBuilder: (child, animation) =>
FadeTransition(opacity: animation, child: child),
child: isSearchEditing.value
? Align(
alignment: Alignment.topCenter,
child: Padding(
padding: EdgeInsets.only(top: filtersTop),
child: SizedBox(
key: const ValueKey('search-header-filters'),
height: searchHeaderFiltersHeight,
child: FilterChipBar<_SearchFilter>(
expandItems: true,
visualDensity: const VisualDensity(
horizontal: -2,
),
chipVerticalPadding:
_searchFilterChipVerticalPadding,
barVerticalPadding:
_searchFilterBarVerticalPadding,
selected: activeFilter.value,
onSelected: (f) => activeFilter.value = f,
items: [
for (final f in _SearchFilter.values)
FilterChipItem(id: f, label: f.label),
],
),
),
const SizedBox(width: Grid.xxs),
if (nativeIos)
IosGlassNavigationButton(
key: const Key('search-close'),
icon: IosGlassNavigationIcon.close,
semanticLabel: 'Close search',
onPressed: closeSearch,
controlSize: 48,
foregroundColor: searchPrimaryColor,
)
else
SizedBox.square(
key: const Key('search-close'),
dimension: 48,
child: IconButton(
tooltip: 'Close search',
onPressed: closeSearch,
style: IconButton.styleFrom(
backgroundColor: searchSurfaceColor,
foregroundColor: searchPrimaryColor,
),
)
: const SizedBox.shrink(
key: ValueKey('search-header-filters-hidden'),
icon: const Icon(BuzzIcons.x),
),
),
],
),
),
SizedBox(
key: const ValueKey('search-header-filters'),
height: filtersHeight,
child: FilterChipBar<_SearchFilter>(
expandItems: true,
visualDensity: const VisualDensity(horizontal: -2),
chipVerticalPadding: _searchFilterChipVerticalPadding,
barVerticalPadding: _searchFilterBarVerticalPadding,
selected: activeFilter.value,
onSelected: (filter) => activeFilter.value = filter,
items: [
for (final filter in _SearchFilter.values)
FilterChipItem(id: filter, label: filter.label),
],
),
),
const SizedBox(height: Grid.xxs),
Expanded(
child: _SearchBody(
state: searchState,
filter: activeFilter.value,
currentPubkey: currentPubkey,
onRecentSearchSelected: runRecentSearch,
),
),
],
),
),
body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(height: topSectionHeight),
Expanded(
child: ClipRRect(
borderRadius: const BorderRadius.vertical(
top: Radius.circular(Radii.dialog),
),
child: Material(
color: context.colors.surface,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: _SearchBody(
state: searchState,
filter: activeFilter.value,
currentPubkey: currentPubkey,
onRecentSearchSelected: runRecentSearch,
),
),
],
),
),
),
),
],
),
);
}
}

View File

@@ -11,7 +11,8 @@ import 'package:buzz/features/search/search_page.dart';
import 'package:buzz/features/search/search_provider.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:buzz/shared/mentions/agent_identity_provider.dart';
import 'package:buzz/shared/widgets/frosted_app_bar.dart';
import 'package:buzz/shared/widgets/concentric_sheet_surface.dart';
import 'package:buzz/shared/widgets/ios_glass_navigation_button.dart';
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -20,12 +21,16 @@ import 'package:buzz/shared/theme/buzz_icons.dart';
import '../../helpers/widget_helpers.dart';
void main() {
for (final reducedMotion in [false, true]) {
testWidgets(
'iOS search field stays in place above tappable chips, reduced motion=$reducedMotion',
(tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
for (final platform in [TargetPlatform.android, TargetPlatform.iOS]) {
for (final scale in [1.0, 2.0]) {
testWidgets('search controls stay fixed on $platform at scale $scale', (
tester,
) async {
debugDefaultTargetPlatformOverride = platform;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [
@@ -39,8 +44,7 @@ void main() {
],
child: MediaQuery(
data: MediaQueryData(
disableAnimations: reducedMotion,
textScaler: TextScaler.linear(reducedMotion ? 2 : 1),
textScaler: TextScaler.linear(scale),
padding: const EdgeInsets.only(top: 59),
),
child: const SearchPage(),
@@ -49,198 +53,55 @@ void main() {
);
await tester.pumpAndSettle();
final field = find.byKey(const Key('search-field-container'));
final idle = tester.getRect(field);
final input = tester.element(find.byType(TextField));
await tester.tap(find.byKey(const Key('search-field')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 80));
final middle = tester.getRect(field);
await tester.pumpAndSettle();
final active = tester.getRect(field);
final cancel = find.byKey(const Key('search-ios-cancel'));
final cancelRect = tester.getRect(cancel);
expect(cancel.hitTestable(), findsOneWidget);
expect(cancelRect.left - active.right, closeTo(Grid.xxs, 0.01));
expect(
tester.getRect(find.byType(FrostedAppBar)).right - cancelRect.right,
closeTo(Grid.gutter, 0.01),
);
expect(active.top, closeTo(idle.top, 0.01));
expect(active.height, closeTo(idle.height, 0.01));
expect(active.width, lessThan(idle.width));
expect(active.top, greaterThanOrEqualTo(59));
if (!reducedMotion) {
expect(middle.top, closeTo(idle.top, 0.01));
final filters = find.byKey(const Key('search-header-filters'));
final close = find.byKey(const Key('search-close'));
final fieldRect = tester.getRect(field);
final filtersRect = tester.getRect(filters);
expect(find.text('Search'), findsNothing);
expect(find.text('Cancel'), findsNothing);
expect(fieldRect.top, greaterThanOrEqualTo(59));
expect(tester.getRect(close).left, greaterThan(fieldRect.right));
expect(tester.getSize(close), const Size(48, 48));
for (final label in ['All', 'Messages', 'Channels', 'People']) {
expect(find.text(label).hitTestable(), findsOneWidget);
}
final input = tester.element(find.byType(TextField));
await tester.tap(find.byType(TextField));
await tester.pumpAndSettle();
expect(tester.getRect(field), fieldRect);
expect(tester.getRect(filters), filtersRect);
expect(tester.element(find.byType(TextField)), same(input));
expect(
tester.widget<TextField>(find.byType(TextField)).focusNode!.hasFocus,
isTrue,
);
final filters = tester.getRect(
find.byKey(const Key('search-header-filters')),
);
expect(filters.top - active.bottom, closeTo(Grid.xxs, 0.01));
expect(
tester.getRect(find.byType(FrostedAppBar)).bottom,
greaterThanOrEqualTo(filters.bottom),
);
for (final label in ['All', 'Messages', 'Channels', 'People']) {
expect(find.text(label).hitTestable(), findsOneWidget);
}
await tester.enterText(find.byType(TextField), 'design');
await tester.tap(cancel);
await tester.tap(find.text('Channels'));
await tester.pumpAndSettle();
expect(tester.getRect(field).top, closeTo(idle.top, 0.01));
await tester.pumpWidget(const SizedBox());
expect(tester.getRect(field), fieldRect);
expect(tester.getRect(filters), filtersRect);
expect(
tester
.widget<ConcentricSheetSurface>(
find.byType(ConcentricSheetSurface),
)
.usesGlass,
isTrue,
);
expect(
find.byType(IosGlassNavigationButton),
platform == TargetPlatform.iOS ? findsOneWidget : findsNothing,
);
expect(tester.takeException(), isNull);
await tester.pumpWidget(const SizedBox.shrink());
debugDefaultTargetPlatformOverride = null;
},
);
});
}
}
testWidgets('iOS recent rows move once through title collapse', (
testWidgets('X dismisses search while the keyboard is focused', (
tester,
) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [
searchProvider.overrideWith(
() => _FakeSearchNotifier(const SearchState.initial()),
),
recentSearchesProvider.overrideWith(
() => _FakeRecentSearchesNotifier(
List.generate(30, (i) => 'Recent query $i'),
),
),
profileProvider.overrideWith(() => _FakeProfileNotifier()),
],
child: const SearchPage(),
),
);
await tester.pumpAndSettle();
final row = find.text('Recent query 1');
final list = find.byKey(const Key('recent-searches-list'));
final gesture = await tester.startGesture(tester.getCenter(list));
await gesture.moveBy(const Offset(0, -20));
await tester.pump();
for (var i = 0; i < 5; i++) {
final before = tester.getTopLeft(row).dy;
await gesture.moveBy(const Offset(0, -20));
await tester.pump();
expect(before - tester.getTopLeft(row).dy, closeTo(20, 1));
}
await gesture.up();
await tester.pumpWidget(const SizedBox());
debugDefaultTargetPlatformOverride = null;
});
testWidgets('reselecting Search uses the field activation path', (
tester,
) async {
final tabReselection = ValueNotifier(0);
addTearDown(tabReselection.dispose);
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [
searchProvider.overrideWith(
() => _FakeSearchNotifier(const SearchState.initial()),
),
recentSearchesProvider.overrideWith(
() => _FakeRecentSearchesNotifier(const []),
),
profileProvider.overrideWith(() => _FakeProfileNotifier()),
],
child: SearchPage(tabReselection: tabReselection),
),
);
await tester.pumpAndSettle();
expect(find.byKey(const Key('search-cancel')), findsNothing);
tabReselection.value++;
await tester.pump();
await tester.pump();
expect(find.byKey(const Key('search-cancel')), findsOneWidget);
expect(
tester.widget<TextField>(find.byType(TextField)).focusNode?.hasFocus,
isTrue,
);
final focusNode = tester
.widget<TextField>(find.byType(TextField))
.focusNode!;
// Reproduce the real tab-tap ordering where the destination callback can
// run immediately before the same pointer gesture dismisses the field.
tabReselection.value++;
focusNode.unfocus();
await tester.pump();
await tester.pump();
expect(find.byKey(const Key('search-cancel')), findsOneWidget);
expect(focusNode.hasFocus, isTrue);
expect(find.byKey(const Key('search-header-title')), findsOneWidget);
});
testWidgets('uses the shared frosted navigation surface', (tester) async {
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [
searchProvider.overrideWith(
() => _FakeSearchNotifier(const SearchState.initial()),
),
recentSearchesProvider.overrideWith(
() => _FakeRecentSearchesNotifier(const []),
),
profileProvider.overrideWith(() => _FakeProfileNotifier()),
],
child: const SearchPage(),
),
);
await tester.pumpAndSettle();
final appBar = tester.widget<FrostedAppBar>(find.byType(FrostedAppBar));
expect(appBar.gradient, isNull);
expect(appBar.frosted, isTrue);
expect(appBar.showBottomDivider, isTrue);
expect(appBar.bottomDividerOpacity, 0.07);
expect(appBar.bottomHeight, 57);
expect(appBar.leading, isNull);
expect(appBar.centerTitle, isFalse);
expect(find.text('Search'), findsOneWidget);
final promptText = find.descendant(
of: find.byKey(const Key('search-field-container')),
matching: find.byType(Text),
);
expect(
tester.getRect(promptText).left,
closeTo(
tester.getRect(find.byKey(const Key('search-moving-icon'))).right +
Grid.xxs,
0.01,
),
);
expect(
tester.getRect(promptText).center.dy,
closeTo(
tester
.getRect(find.byKey(const Key('search-field-container')))
.center
.dy,
0.5,
),
);
});
testWidgets('empty state preserves large accessible text scaling', (
tester,
) async {
tester.view.physicalSize = const Size(320, 640);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [
@@ -253,124 +114,31 @@ void main() {
profileProvider.overrideWith(() => _FakeProfileNotifier()),
],
child: Builder(
builder: (context) => MediaQuery(
data: MediaQuery.of(
builder: (context) => TextButton(
onPressed: () => Navigator.of(
context,
).copyWith(textScaler: const TextScaler.linear(2)),
child: const SearchPage(),
).push(MaterialPageRoute<void>(builder: (_) => const SearchPage())),
child: const Text('Open search'),
),
),
),
);
await tester.tap(find.text('Open search'));
await tester.pumpAndSettle();
final appBar = tester.widget<FrostedAppBar>(find.byType(FrostedAppBar));
final titleStyle = appBar.titleStyle!;
expect(titleStyle.fontSize, 22);
expect(
tester
.getSize(
find
.descendant(
of: find.byType(FrostedAppBar),
matching: find.byType(ClipRect),
)
.first,
)
.height,
closeTo(
frostedAppBarHeight(
tester.element(find.byType(FrostedAppBar)),
titleStyle: titleStyle,
bottomHeight: appBar.bottomHeight,
),
0.01,
),
);
final emptyState = find.byKey(const Key('search-empty-state'));
final searchField = find.byKey(const Key('search-field-container'));
final searchFieldContext = tester.element(searchField);
final bodyStyle = Theme.of(searchFieldContext).textTheme.bodyMedium!;
final scaledLineHeight =
MediaQuery.textScalerOf(searchFieldContext).scale(bodyStyle.fontSize!) *
bodyStyle.height!;
expect(
find.descendant(of: emptyState, matching: find.byType(FittedBox)),
findsNothing,
);
expect(
tester.getSize(searchField).height,
greaterThanOrEqualTo(scaledLineHeight + Grid.xxs * 2),
);
final prompt = tester.widget<Text>(
find.descendant(
of: find.byKey(const Key('search-field-container')),
matching: find.byType(Text),
),
);
expect(prompt.style?.fontSize, 15);
expect(prompt.maxLines, 1);
expect(prompt.overflow, TextOverflow.ellipsis);
expect(
tester
.getSize(find.descendant(of: emptyState, matching: find.byType(Text)))
.height,
greaterThan(32),
);
expect(tester.takeException(), isNull);
await tester.tap(find.byType(TextField));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), 'draft');
await tester.tap(find.byTooltip('Close search'));
await tester.pumpAndSettle();
expect(find.byType(SearchPage), findsNothing);
expect(find.text('Open search'), findsOneWidget);
});
testWidgets('search filters grow with accessible text', (tester) async {
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [
searchProvider.overrideWith(
() => _FakeSearchNotifier(const SearchState.initial()),
),
recentSearchesProvider.overrideWith(
() => _FakeRecentSearchesNotifier(const []),
),
profileProvider.overrideWith(() => _FakeProfileNotifier()),
],
child: Builder(
builder: (context) => MediaQuery(
data: MediaQuery.of(
context,
).copyWith(textScaler: const TextScaler.linear(2)),
child: const SearchPage(),
),
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('search-field')));
await tester.pumpAndSettle();
final filters = find.byKey(const Key('search-header-filters'));
final activeField = find.byKey(const Key('search-field-container'));
final cancel = find.byKey(const Key('search-cancel'));
expect(filters, findsOneWidget);
expect(cancel, findsOneWidget);
expect(
tester.getRect(activeField).right,
lessThanOrEqualTo(tester.getRect(cancel).left),
reason: 'Scaled Cancel must not overlap the active search field.',
);
expect(tester.getSize(filters).height, greaterThan(Grid.xl));
expect(
tester.getSize(filters).height,
greaterThanOrEqualTo(
tester.getSize(find.text('Messages')).height + Grid.xs * 2,
),
);
expect(tester.takeException(), isNull);
});
testWidgets('focus shows Cancel and chips without moving the search field', (
testWidgets('reselecting Search restores input focus without moving tabs', (
tester,
) async {
final reselection = ValueNotifier(0);
addTearDown(reselection.dispose);
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [
@@ -382,124 +150,24 @@ void main() {
),
profileProvider.overrideWith(() => _FakeProfileNotifier()),
],
child: const SearchPage(),
child: SearchPage(tabReselection: reselection),
),
);
await tester.pumpAndSettle();
final searchField = find.byKey(const Key('search-field'));
final editingField = find.byType(TextField);
final searchFieldContainer = find.byKey(
const Key('search-field-container'),
);
final unfocusedWidth = tester.getSize(searchFieldContainer).width;
final unfocusedTop = tester.getRect(searchFieldContainer).top;
expect(find.byKey(const Key('search-cancel')), findsNothing);
await tester.tap(searchField);
await tester.pump();
final cancel = find.byKey(const Key('search-cancel'));
expect(cancel, findsOneWidget);
expect(
tester.getSize(cancel).height,
greaterThanOrEqualTo(Grid.xl),
reason: 'Cancel must keep a 48dp touch target.',
);
final input = tester.widget<TextField>(editingField);
expect(input.decoration?.hintText, isNull);
expect(input.textInputAction, TextInputAction.search);
expect(
input.focusNode?.hasFocus,
isTrue,
reason: 'Tapping the idle search field opens the native keyboard.',
);
await tester.pump(const Duration(milliseconds: 160));
final focusedWidth = tester.getSize(searchFieldContainer).width;
final focusedRect = tester.getRect(searchFieldContainer);
expect(focusedWidth, lessThan(unfocusedWidth));
expect(
focusedRect.top,
closeTo(unfocusedTop, 0.01),
reason: 'The active field stays below the header.',
);
expect(find.byKey(const Key('search-header-filters')), findsOneWidget);
final filtersRect = tester.getRect(
final filters = tester.getRect(
find.byKey(const Key('search-header-filters')),
);
expect(
filtersRect.top - focusedRect.bottom,
Grid.xxs,
reason: 'Filters keep one compact spacing token below the controls.',
);
final movingIcon = find.byKey(const Key('search-moving-icon'));
final iconScale = tester.widget<AnimatedScale>(
find.ancestor(of: movingIcon, matching: find.byType(AnimatedScale)),
);
final movingField = tester.widget<AnimatedPositioned>(
find.ancestor(of: movingIcon, matching: find.byType(AnimatedPositioned)),
);
expect(iconScale.scale, lessThan(1));
expect(movingField.top, Grid.half);
final appBarRect = tester.getRect(find.byType(FrostedAppBar));
expect(
appBarRect.bottom - filtersRect.bottom,
closeTo(Grid.xxs + 1, 0.01),
reason:
'The filter row keeps the same spacing below it, plus the divider.',
);
expect(
appBarRect.contains(focusedRect.center),
isTrue,
reason: 'The field remains inside the app bar hit-test box.',
);
await tester.enterText(editingField, 'design');
await tester.tap(cancel);
await tester.pump();
expect(find.byKey(const Key('search-header-title')), findsOneWidget);
reselection.value++;
await tester.pumpAndSettle();
expect(find.byKey(const Key('search-cancel')), findsNothing);
expect(
tester.getSize(searchFieldContainer).width,
closeTo(unfocusedWidth, 0.01),
);
});
testWidgets('keeps the search prompt calm until it is focused', (
tester,
) async {
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [
searchProvider.overrideWith(
() => _FakeSearchNotifier(const SearchState.initial()),
),
recentSearchesProvider.overrideWith(
() => _FakeRecentSearchesNotifier(const []),
),
profileProvider.overrideWith(() => _FakeProfileNotifier()),
],
child: const SearchPage(),
),
);
final focus = tester.widget<TextField>(find.byType(TextField)).focusNode!;
expect(focus.hasFocus, isTrue);
reselection.value++;
focus.unfocus();
await tester.pumpAndSettle();
final searchField = find.byKey(const Key('search-field'));
final searchFieldContainer = find.byKey(
const Key('search-field-container'),
);
expect(find.text('Messages'), findsNothing);
expect(tester.getSize(searchFieldContainer).height, greaterThan(36));
await tester.tap(searchField);
await tester.pumpAndSettle();
expect(find.text('Messages'), findsOneWidget);
expect(focus.hasFocus, isTrue);
expect(
tester.getSize(searchFieldContainer).height,
greaterThanOrEqualTo(36),
tester.getRect(find.byKey(const Key('search-header-filters'))),
filters,
);
});
@@ -526,7 +194,7 @@ void main() {
await tester.tap(searchField);
await tester.pumpAndSettle();
await tester.enterText(searchField, 'draft');
await tester.tap(find.byKey(const Key('search-cancel')));
await tester.enterText(find.byKey(const Key('search-field')), '');
await tester.pumpAndSettle();
expect(find.byKey(const Key('recent-searches-list')), findsNothing);
@@ -535,11 +203,11 @@ void main() {
await tester.enterText(searchField, 'design systems');
await tester.testTextInput.receiveAction(TextInputAction.search);
await tester.pumpAndSettle();
expect(find.byKey(const Key('search-cancel')), findsNothing);
expect(find.byKey(const Key('search-close')), findsOneWidget);
// Submission dismisses editing immediately. Reopen before clearing.
await tester.tap(searchField);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('search-cancel')));
await tester.enterText(find.byKey(const Key('search-field')), '');
await tester.pumpAndSettle();
expect(find.byKey(const Key('recent-searches-list')), findsOneWidget);
@@ -588,7 +256,7 @@ void main() {
expect(input.focusNode?.hasFocus, isTrue);
expect(find.text("No results for 'launch plan'"), findsOneWidget);
await tester.tap(find.byKey(const Key('search-cancel')));
await tester.enterText(find.byKey(const Key('search-field')), '');
await tester.pumpAndSettle();
expect(find.byKey(const Key('recent-search-0')), findsOneWidget);
expect(find.text('launch plan'), findsOneWidget);