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:
co-authored by
Claude Sonnet 4.6
parent
2b260edb52
commit
65c75c365a
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
---
|
||||
|
||||
@@ -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]),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user