feat: replace custom search TextField with Flutter SearchBar widget

SearchBar is always visible in the AppBar bottom slot — no toggle needed.
Removed _isSearching, manual debounce timer, and slide animation.
SearchController listener clears results when text is emptied.
Updated E2E and widget tests for the new widget tree.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Thomas Güttler
2026-04-27 08:04:20 +02:00
co-authored by Claude Sonnet 4.6
parent 2b260edb52
commit 65c75c365a
5 changed files with 76 additions and 101 deletions
+26
View File
@@ -6,6 +6,32 @@ Tasks get moved from next.md to done.md
## Tasks
## Replace custom search TextField with Flutter SearchBar
Replaced the hand-rolled `TextField`-in-`AppBar` search UI with Flutter's built-in `SearchBar`
widget (Material 3). The `SearchBar` is now always visible in the `AppBar`'s `bottom` slot — no
toggle needed.
Removed: `bool _searching` state field, `TextEditingController _searchCtrl`, `Timer? _searchDebounce`,
and the `_searchBar()` / `_closeSearch()` helpers.
Added: `SearchController _searchController` with a listener that clears results when text is
emptied. `onChanged` fires search immediately (no debounce); `onSubmitted` also fires it. A clear
`IconButton` appears in `trailing` when the controller has text.
Updated `integration_test/app_e2e_test.dart`: search section now enters text directly into
`find.byType(SearchBar)` — no icon tap or `TextField` lookup needed.
Updated widget tests in `test/widget/email_list_screen_test.dart`: replaced the "tapping back
arrow" test with "SearchBar is always visible in the AppBar"; fixed "clear results" test to use
`emails: []` so the stream body stays empty after clearing.
## Sieve Scripts editing is discoverable
The Sieve script editor ("Email filters") was already implemented. It became reachable
via the "Email filters" entry added to `FolderDrawer` in the previous task — no further
code changes needed.
## MX record fallback in account auto-discovery
When JMAP well-known and autoconfig XML both fail, `AccountDiscoveryServiceImpl` now
+2 -6
View File
@@ -302,19 +302,15 @@ void main() {
expect(find.text(subject), findsOneWidget);
// ── Search ─────────────────────────────────────────────────────────────
await tester.tap(find.byIcon(Icons.search));
await pumpUntil(tester, find.byType(TextField));
// Search by the 'E2E-' prefix — should match the message we just sent.
// SearchBar is always visible in the AppBar bottom; no icon tap needed.
_log('search');
await tester.enterText(find.byType(TextField), 'E2E-');
await tester.enterText(find.byType(SearchBar), 'E2E-');
// Dismiss the IME keyboard so the results ListView.builder gets full
// body height. On Android the soft keyboard reduces viewInsets.bottom,
// leaving near-zero height for the body — ListView.builder then renders
// 0 items and find.text() always fails even when results are present.
FocusManager.instance.primaryFocus?.unfocus();
// Future.delayed advances real time so the 300ms debounce Timer fires.
await Future.delayed(const Duration(milliseconds: 500));
await tester.pump();
await pumpUntil(
+44 -59
View File
@@ -28,11 +28,10 @@ class EmailListScreen extends ConsumerStatefulWidget {
}
class _EmailListScreenState extends ConsumerState<EmailListScreen> {
bool _searching = false;
final _searchCtrl = TextEditingController();
Timer? _searchDebounce;
final _searchController = SearchController();
List<Email>? _searchResults;
bool _searchLoading = false;
bool get _searching => _searchController.text.isNotEmpty;
// Thread-level selection (key = threadId).
final Set<String> _selectedThreadIds = {};
@@ -43,10 +42,22 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
bool get _selecting =>
_selectedThreadIds.isNotEmpty || _selectedSearchIds.isNotEmpty;
@override
void initState() {
super.initState();
_searchController.addListener(() {
if (_searchController.text.isEmpty) {
setState(() {
_searchResults = null;
_searchLoading = false;
});
}
});
}
@override
void dispose() {
_searchDebounce?.cancel();
_searchCtrl.dispose();
_searchController.dispose();
super.dispose();
}
@@ -102,13 +113,8 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
}
}
void _closeSearch() {
_searchDebounce?.cancel();
setState(() {
_searching = false;
_searchResults = null;
_searchCtrl.clear();
});
void _onSearchChanged(String value) {
if (value.trim().isNotEmpty) unawaited(_runSearch(value.trim()));
}
@override
@@ -116,17 +122,17 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
final repo = ref.watch(emailRepositoryProvider);
final accountAsync = ref.watch(accountByIdProvider(widget.accountId));
return Scaffold(
appBar: _selecting
? _selectionBar()
: (_searching ? _searchBar() : _normalBar(repo, accountAsync)),
drawer: (_selecting || _searching)
appBar: _selecting ? _selectionBar() : _normalBar(repo, accountAsync),
drawer: _selecting
? null
: FolderDrawer(
accountId: widget.accountId,
currentMailboxPath: widget.mailboxPath,
),
bottomNavigationBar: _selecting ? _selectionBottomBar() : null,
body: _searching ? _buildSearchBody() : _buildStreamBody(repo),
body: (_searchResults != null || _searchLoading)
? _buildSearchBody()
: _buildStreamBody(repo),
);
}
@@ -150,11 +156,6 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
),
),
),
IconButton(
icon: const Icon(Icons.search),
tooltip: 'Search',
onPressed: () => setState(() => _searching = true),
),
IconButton(
icon: const Icon(Icons.sync),
onPressed: () async {
@@ -179,6 +180,27 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
),
),
],
bottom: PreferredSize(
preferredSize: const Size.fromHeight(60),
child: Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
child: SearchBar(
controller: _searchController,
hintText: 'Search…',
leading: const Icon(Icons.search),
trailing: [
if (_searchController.text.isNotEmpty)
IconButton(
icon: const Icon(Icons.clear),
onPressed: () => _searchController.clear(),
),
],
onChanged: _onSearchChanged,
onSubmitted: _runSearch,
textInputAction: TextInputAction.search,
),
),
),
);
}
@@ -194,43 +216,6 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
);
}
AppBar _searchBar() {
return AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: _closeSearch,
),
title: TextField(
controller: _searchCtrl,
autofocus: true,
decoration: const InputDecoration(
hintText: 'Search…',
border: InputBorder.none,
),
onChanged: (value) {
_searchDebounce?.cancel();
_searchDebounce = Timer(
const Duration(milliseconds: 300),
() => _runSearch(value),
);
},
onSubmitted: _runSearch,
textInputAction: TextInputAction.search,
),
actions: [
if (_searchCtrl.text.isNotEmpty)
IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchDebounce?.cancel();
_searchCtrl.clear();
setState(() => _searchResults = null);
},
),
],
);
}
Widget _selectionBottomBar() {
return BottomAppBar(
child: Row(
-27
View File
@@ -17,30 +17,3 @@ Git repo should not contain unknown files.
Then commit.
## Tasks
How can I edit Sieve Scripts? Afaik this feature was added.
---
Replace the custom TextField-in-AppBar search implementation in
lib/ui/screens/email_list_screen.dart with Flutter's built-in SearchBar / SearchAnchor widget
(Flutter 3.x).
Goals:
Remove the _isSearching bool, the _searchController, the slide animation, and the manual Timer
debounce
Use SearchAnchor + SearchBar to drive the _searchQuery state that filters the email list
Keep the existing filter logic untouched — just replace the input mechanism
The search bar should live in the AppBar area; if SearchAnchor doesn't fit cleanly there, use
SearchBar standalone with onChanged (no debounce needed — let the user control submit, or accept
instant filter)
Preserve all existing AppBar actions (compose, sync, settings) when search is not active
Update or remove any tests in integration_test/ that relied on the old search widget tree
---
+4 -9
View File
@@ -189,7 +189,7 @@ void main() {
expect(find.text('To'), findsOneWidget);
});
testWidgets('tapping back arrow in search bar closes it', (tester) async {
testWidgets('SearchBar is always visible in the AppBar', (tester) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
@@ -204,13 +204,8 @@ void main() {
);
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.search));
await tester.pumpAndSettle();
await tester.tap(find.byIcon(Icons.arrow_back));
await tester.pumpAndSettle();
expect(find.text('Search…'), findsNothing);
expect(find.byType(SearchBar), findsOneWidget);
expect(find.text('Search…'), findsOneWidget);
expect(find.text('INBOX'), findsOneWidget);
});
@@ -280,7 +275,7 @@ void main() {
mailboxRepositoryProvider
.overrideWithValue(FakeMailboxRepository()),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(emails: [email], searchResults: [email]),
FakeEmailRepository(emails: [], searchResults: [email]),
),
],
),