Closes #533. Pull selection, swipe, pagination and batch actions out of three near-duplicate screens (EmailListScreen, CombinedInboxScreen, AddressEmailsScreen) into a single shared widget. Folder view, combined inbox, in-folder search results and by-address lists now share one tile renderer, one selection controller and one batch-action bottom bar. - New EmailThreadList widget + EmailThreadListController own the list rendering, selection set, optional swipe-to-archive/delete and optional pagination. Hosts listen to the controller to swap between their normal AppBar/drawer/FAB and the shared selection AppBar / BottomAppBar (buildSelectionAppBar, buildSelectionBottomBar). - Batch actions (batchArchive, batchDelete, batchMarkSpam, batchMove, batchSnooze) and swipeDismissThread move to email_action_helpers.dart and group threads by account so multi- account selections produce correctly scoped repository calls and undo actions. The combined inbox now supports the full action set (was archive + delete only). - The duplicate EmailThreadTile widget is removed; ThreadTile is the single tile used everywhere. Search results now render with the same unread/flag icons as the inbox list. - AddressEmailsScreen adopts the shared list, gaining selection + batch actions for free. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
108 lines
3.3 KiB
Dart
108 lines
3.3 KiB
Dart
import 'package:flutter_test/flutter_test.dart';
|
|
|
|
import 'package:sharedinbox/core/models/email.dart';
|
|
import 'package:sharedinbox/ui/widgets/email_thread_list.dart';
|
|
|
|
EmailThread _t(String id, {String accountId = 'acc-1'}) => EmailThread(
|
|
threadId: id,
|
|
subject: id,
|
|
participants: const [],
|
|
latestDate: DateTime(2024, 6),
|
|
messageCount: 1,
|
|
hasUnread: false,
|
|
isFlagged: false,
|
|
latestEmailId: id,
|
|
emailIds: [id],
|
|
accountId: accountId,
|
|
mailboxPath: 'INBOX',
|
|
);
|
|
|
|
void main() {
|
|
group('EmailThreadListController', () {
|
|
test('toggle adds then removes a thread id and fires notifications', () {
|
|
final ctrl = EmailThreadListController()
|
|
..updateThreads([_t('a'), _t('b')]);
|
|
var notifications = 0;
|
|
ctrl.addListener(() => notifications++);
|
|
|
|
expect(ctrl.isSelecting, isFalse);
|
|
|
|
ctrl.toggle(_t('a'));
|
|
expect(ctrl.isSelecting, isTrue);
|
|
expect(ctrl.selectionCount, 1);
|
|
expect(ctrl.isSelected(_t('a')), isTrue);
|
|
expect(notifications, 1);
|
|
|
|
ctrl.toggle(_t('a'));
|
|
expect(ctrl.isSelecting, isFalse);
|
|
expect(ctrl.selectionCount, 0);
|
|
expect(notifications, 2);
|
|
});
|
|
|
|
test('selectAll selects every visible thread', () {
|
|
final ctrl = EmailThreadListController()
|
|
..updateThreads([_t('a'), _t('b'), _t('c')]);
|
|
ctrl.selectAll();
|
|
expect(ctrl.selectionCount, 3);
|
|
expect(ctrl.selectedIds, {'a', 'b', 'c'});
|
|
});
|
|
|
|
test('clear empties the selection and notifies once', () {
|
|
final ctrl = EmailThreadListController()
|
|
..updateThreads([_t('a'), _t('b')])
|
|
..toggle(_t('a'))
|
|
..toggle(_t('b'));
|
|
var notifications = 0;
|
|
ctrl.addListener(() => notifications++);
|
|
|
|
ctrl.clear();
|
|
expect(ctrl.isSelecting, isFalse);
|
|
expect(notifications, 1);
|
|
|
|
// Clearing an already-empty selection does not notify again.
|
|
ctrl.clear();
|
|
expect(notifications, 1);
|
|
});
|
|
|
|
test('updateThreads drops selections that are no longer visible', () {
|
|
final ctrl = EmailThreadListController()
|
|
..updateThreads([_t('a'), _t('b'), _t('c')])
|
|
..toggle(_t('a'))
|
|
..toggle(_t('c'));
|
|
expect(ctrl.selectionCount, 2);
|
|
|
|
ctrl.updateThreads([_t('a'), _t('b')]);
|
|
// 'c' is no longer visible, so it gets dropped.
|
|
expect(ctrl.selectionCount, 1);
|
|
expect(ctrl.selectedIds, {'a'});
|
|
});
|
|
|
|
test('selectedThreads preserves the visible-list order', () {
|
|
final a = _t('a');
|
|
final b = _t('b');
|
|
final c = _t('c');
|
|
final ctrl = EmailThreadListController()
|
|
..updateThreads([a, b, c])
|
|
..toggle(c)
|
|
..toggle(a);
|
|
// Selection order is insertion (c, a), but selectedThreads must follow
|
|
// the visible-list order (a, c).
|
|
expect(ctrl.selectedThreads.map((t) => t.threadId), ['a', 'c']);
|
|
});
|
|
|
|
test('multi-account threads are kept independent in the selection', () {
|
|
final ctrl = EmailThreadListController()
|
|
..updateThreads([
|
|
_t('a', accountId: 'acc-1'),
|
|
_t('b', accountId: 'acc-2'),
|
|
]);
|
|
ctrl.selectAll();
|
|
final byAccount = <String, int>{};
|
|
for (final t in ctrl.selectedThreads) {
|
|
byAccount[t.accountId] = (byAccount[t.accountId] ?? 0) + 1;
|
|
}
|
|
expect(byAccount, {'acc-1': 1, 'acc-2': 1});
|
|
});
|
|
});
|
|
}
|