feat: bulk actions on search results via long-press selection

Long-pressing a search result enters selection mode; tapping further
results toggles them. The existing bottom bar (Archive, Delete, Mark
as spam, Move to folder) operates on the selected emails via a new
_selectedSearchIds set. _selectedEmailIds returns the right IDs for
each mode (search vs. normal thread list).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Thomas Güttler
2026-04-27 07:32:44 +02:00
co-authored by Claude Sonnet 4.6
parent 2074046bb3
commit 527683172c
3 changed files with 64 additions and 24 deletions
+12
View File
@@ -6,6 +6,18 @@ Tasks get moved from next.md to done.md
## Tasks
## Bulk actions on search results
Long-pressing a search result enters selection mode; tapping additional results adds/removes
them. The existing bottom bar (Archive, Delete, Mark as spam, Move to folder) works on the
selection. Implementation in `email_list_screen.dart`:
- `_selectedSearchIds` (`Set<String>`) tracks selected email IDs in search results.
- `_selecting` is true when either `_selectedThreadIds` or `_selectedSearchIds` is non-empty.
- `_selectedEmailIds` returns `_selectedSearchIds` when searching, thread-resolved IDs otherwise.
- `_buildEmailList` shows checkboxes in selection mode, highlights selected tiles, and routes
taps to toggle-vs-open depending on mode.
## Multi-word search uses AND semantics
Searching for "foo bar" now returns emails that contain **both** words, not the exact
+52 -19
View File
@@ -38,7 +38,10 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
final Set<String> _selectedThreadIds = {};
// Last-emitted thread list, used to resolve emailIds for batch operations.
List<EmailThread> _currentThreads = [];
bool get _selecting => _selectedThreadIds.isNotEmpty;
// Individual email selection used in search results.
final Set<String> _selectedSearchIds = {};
bool get _selecting =>
_selectedThreadIds.isNotEmpty || _selectedSearchIds.isNotEmpty;
@override
void dispose() {
@@ -57,13 +60,29 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
});
}
void _clearSelection() => setState(() => _selectedThreadIds.clear());
void _clearSelection() => setState(() {
_selectedThreadIds.clear();
_selectedSearchIds.clear();
});
// All email IDs belonging to currently selected threads.
List<String> get _selectedEmailIds => _currentThreads
.where((t) => _selectedThreadIds.contains(t.threadId))
.expand((t) => t.emailIds)
.toList();
void _toggleSearchSelection(String emailId) {
setState(() {
if (_selectedSearchIds.contains(emailId)) {
_selectedSearchIds.remove(emailId);
} else {
_selectedSearchIds.add(emailId);
}
});
}
// All email IDs for the current selection context.
List<String> get _selectedEmailIds {
if (_searching) return _selectedSearchIds.toList();
return _currentThreads
.where((t) => _selectedThreadIds.contains(t.threadId))
.expand((t) => t.emailIds)
.toList();
}
Future<void> _runSearch(String query) async {
if (query.trim().isEmpty) {
@@ -164,12 +183,14 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
}
AppBar _selectionBar() {
final count =
_searching ? _selectedSearchIds.length : _selectedThreadIds.length;
return AppBar(
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: _clearSelection,
),
title: Text('${_selectedThreadIds.length} selected'),
title: Text('$count selected'),
);
}
@@ -529,14 +550,20 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
itemCount: emails.length,
itemBuilder: (ctx, i) {
final e = emails[i];
final isSelected = _selectedSearchIds.contains(e.id);
final sender = e.from.isNotEmpty
? (e.from.first.name ?? e.from.first.email)
: '(unknown)';
return ListTile(
leading: Icon(
e.isSeen ? Icons.mail_outline : Icons.mail,
color: e.isSeen ? null : Theme.of(ctx).colorScheme.primary,
),
leading: _selecting
? Checkbox(
value: isSelected,
onChanged: (_) => _toggleSearchSelection(e.id),
)
: Icon(
e.isSeen ? Icons.mail_outline : Icons.mail,
color: e.isSeen ? null : Theme.of(ctx).colorScheme.primary,
),
title: Text(
sender,
style:
@@ -547,13 +574,19 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: Text(
e.sentAt != null ? _dateFmt.format(e.sentAt!) : '',
style: Theme.of(ctx).textTheme.bodySmall,
),
onTap: () => context.push(
'/accounts/${widget.accountId}/mailboxes/${Uri.encodeComponent(widget.mailboxPath)}/emails/${Uri.encodeComponent(e.id)}',
),
selected: isSelected,
trailing: _selecting
? null
: Text(
e.sentAt != null ? _dateFmt.format(e.sentAt!) : '',
style: Theme.of(ctx).textTheme.bodySmall,
),
onTap: _selecting
? () => _toggleSearchSelection(e.id)
: () => context.push(
'/accounts/${widget.accountId}/mailboxes/${Uri.encodeComponent(widget.mailboxPath)}/emails/${Uri.encodeComponent(e.id)}',
),
onLongPress: () => _toggleSearchSelection(e.id),
);
},
);
-5
View File
@@ -18,11 +18,6 @@ Then commit.
## Tasks
I search for "foo". Now I see all mails containing "foo". I want to easily do the common actions on
the selected mails: Delete, Archive, Move to Folder, Move to Junk, ...
---
How can I edit the Sieve Filter?
---