From 65c75c365acd6f4b4ed7e1d94c0a1781586f1b1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=BCttler?= Date: Mon, 27 Apr 2026 08:04:20 +0200 Subject: [PATCH] feat: replace custom search TextField with Flutter SearchBar widget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- done.md | 26 ++++++ integration_test/app_e2e_test.dart | 8 +- lib/ui/screens/email_list_screen.dart | 103 ++++++++++-------------- next.md | 27 ------- test/widget/email_list_screen_test.dart | 13 +-- 5 files changed, 76 insertions(+), 101 deletions(-) diff --git a/done.md b/done.md index 0a48588..6441892 100644 --- a/done.md +++ b/done.md @@ -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 diff --git a/integration_test/app_e2e_test.dart b/integration_test/app_e2e_test.dart index e8fc9d9..d4272ed 100644 --- a/integration_test/app_e2e_test.dart +++ b/integration_test/app_e2e_test.dart @@ -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( diff --git a/lib/ui/screens/email_list_screen.dart b/lib/ui/screens/email_list_screen.dart index 042de91..ffda296 100644 --- a/lib/ui/screens/email_list_screen.dart +++ b/lib/ui/screens/email_list_screen.dart @@ -28,11 +28,10 @@ class EmailListScreen extends ConsumerStatefulWidget { } class _EmailListScreenState extends ConsumerState { - bool _searching = false; - final _searchCtrl = TextEditingController(); - Timer? _searchDebounce; + final _searchController = SearchController(); List? _searchResults; bool _searchLoading = false; + bool get _searching => _searchController.text.isNotEmpty; // Thread-level selection (key = threadId). final Set _selectedThreadIds = {}; @@ -43,10 +42,22 @@ class _EmailListScreenState extends ConsumerState { 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 { } } - 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 { 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 { ), ), ), - 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 { ), ), ], + 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 { ); } - 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( diff --git a/next.md b/next.md index dba6801..470f762 100644 --- a/next.md +++ b/next.md @@ -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 - ---- diff --git a/test/widget/email_list_screen_test.dart b/test/widget/email_list_screen_test.dart index 772d92a..84b7b19 100644 --- a/test/widget/email_list_screen_test.dart +++ b/test/widget/email_list_screen_test.dart @@ -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]), ), ], ),