diff --git a/lib/ui/screens/address_emails_screen.dart b/lib/ui/screens/address_emails_screen.dart index fd1b56a..9566e93 100644 --- a/lib/ui/screens/address_emails_screen.dart +++ b/lib/ui/screens/address_emails_screen.dart @@ -2,10 +2,10 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; import 'package:sharedinbox/core/models/email.dart'; import 'package:sharedinbox/di.dart'; +import 'package:sharedinbox/ui/widgets/email_thread_list.dart'; class AddressEmailsScreen extends ConsumerStatefulWidget { const AddressEmailsScreen({ @@ -26,12 +26,27 @@ class _AddressEmailsScreenState extends ConsumerState { List? _emails; bool _loading = true; + late final EmailThreadListController _selection; + @override void initState() { super.initState(); + _selection = EmailThreadListController()..addListener(_onSelectionChange); unawaited(_load()); } + @override + void dispose() { + _selection + ..removeListener(_onSelectionChange) + ..dispose(); + super.dispose(); + } + + void _onSelectionChange() { + if (mounted) setState(() {}); + } + Future _load() async { final emails = await ref .read(emailRepositoryProvider) @@ -46,43 +61,35 @@ class _AddressEmailsScreenState extends ConsumerState { @override Widget build(BuildContext context) { + final selecting = _selection.isSelecting; return Scaffold( - appBar: AppBar(title: Text(widget.address)), + appBar: selecting + ? buildSelectionAppBar(_selection) + : AppBar(title: Text(widget.address)), + bottomNavigationBar: selecting + ? buildSelectionBottomBar( + context, + ref, + _selection, + onAfterAction: _onAfterBatchAction, + ) + : null, body: _loading ? const Center(child: CircularProgressIndicator()) - : _emails!.isEmpty - ? const Center(child: Text('No emails')) - : ListView.builder( - itemCount: _emails!.length, - itemBuilder: (ctx, i) { - final e = _emails![i]; - 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, - ), - title: Text(sender), - subtitle: Text( - e.subject ?? '(no subject)', - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - trailing: Text( - e.mailboxPath, - style: Theme.of(ctx).textTheme.bodySmall, - ), - onTap: () => context.push( - '/accounts/${widget.accountId}/mailboxes' - '/${Uri.encodeComponent(e.mailboxPath)}' - '/emails/${Uri.encodeComponent(e.id)}', - ), - ); - }, - ), + : EmailThreadList( + controller: _selection, + items: _emails!.map(EmailThread.fromEmail).toList(), + enableSwipe: false, + showLocationLabel: true, + ), ); } + + void _onAfterBatchAction(List actedThreadIds) { + if (_emails == null || !mounted) return; + final actedSet = actedThreadIds.toSet(); + final remaining = + _emails!.where((e) => !actedSet.contains(e.threadId ?? e.id)).toList(); + setState(() => _emails = remaining); + } } diff --git a/lib/ui/screens/combined_inbox_screen.dart b/lib/ui/screens/combined_inbox_screen.dart index 6f2c976..1692859 100644 --- a/lib/ui/screens/combined_inbox_screen.dart +++ b/lib/ui/screens/combined_inbox_screen.dart @@ -5,10 +5,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:sharedinbox/core/models/account.dart'; -import 'package:sharedinbox/core/models/email.dart'; -import 'package:sharedinbox/core/models/undo_action.dart'; import 'package:sharedinbox/di.dart'; -import 'package:sharedinbox/ui/widgets/email_thread_tile.dart'; +import 'package:sharedinbox/ui/widgets/email_thread_list.dart'; class CombinedInboxScreen extends ConsumerStatefulWidget { const CombinedInboxScreen({super.key}); @@ -22,29 +20,24 @@ class _CombinedInboxScreenState extends ConsumerState { static const _pageSize = 50; int _limit = _pageSize; - // Thread-level selection (key = threadId). - final Set _selectedThreadIds = {}; - // Last-emitted thread list, used to resolve emailIds for batch operations. - List _currentThreads = []; + late final EmailThreadListController _selection; - bool get _selecting => _selectedThreadIds.isNotEmpty; - - void _toggleThreadSelection(EmailThread thread) { - setState(() { - if (_selectedThreadIds.contains(thread.threadId)) { - _selectedThreadIds.remove(thread.threadId); - } else { - _selectedThreadIds.add(thread.threadId); - } - }); + @override + void initState() { + super.initState(); + _selection = EmailThreadListController()..addListener(_onSelectionChange); } - void _clearSelection() => setState(() => _selectedThreadIds.clear()); + @override + void dispose() { + _selection + ..removeListener(_onSelectionChange) + ..dispose(); + super.dispose(); + } - void _selectAll() { - setState( - () => _selectedThreadIds.addAll(_currentThreads.map((t) => t.threadId)), - ); + void _onSelectionChange() { + if (mounted) setState(() {}); } @override @@ -72,13 +65,18 @@ class _CombinedInboxScreenState extends ConsumerState { for (final a in accounts) a.id: a.displayName, }; final showAccount = accounts.length > 1; + final selecting = _selection.isSelecting; return Scaffold( - appBar: _buildAppBar(accounts), - drawer: _selecting ? null : _buildDrawer(context, accounts), - bottomNavigationBar: _selecting ? _selectionBottomBar() : null, + appBar: selecting + ? buildSelectionAppBar(_selection) + : _buildAppBar(accounts), + drawer: selecting ? null : _buildDrawer(context, accounts), + bottomNavigationBar: selecting + ? buildSelectionBottomBar(context, ref, _selection) + : null, body: _buildBody(accountNames, showAccount), - floatingActionButton: _selecting + floatingActionButton: selecting ? null : FloatingActionButton( onPressed: () => context.push('/compose'), @@ -90,23 +88,6 @@ class _CombinedInboxScreenState extends ConsumerState { } PreferredSizeWidget _buildAppBar(List accounts) { - if (_selecting) { - return AppBar( - leading: IconButton( - icon: const Icon(Icons.close), - onPressed: _clearSelection, - ), - title: Text('${_selectedThreadIds.length} selected'), - actions: [ - IconButton( - icon: const Icon(Icons.select_all), - tooltip: 'Select all', - onPressed: _selectAll, - ), - ], - ); - } - return AppBar( title: const Text('Combined Inbox'), actions: [ @@ -128,26 +109,6 @@ class _CombinedInboxScreenState extends ConsumerState { ); } - Widget _selectionBottomBar() { - return BottomAppBar( - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - IconButton( - icon: const Icon(Icons.archive), - tooltip: 'Archive', - onPressed: _batchArchive, - ), - IconButton( - icon: const Icon(Icons.delete), - tooltip: 'Delete', - onPressed: _batchDelete, - ), - ], - ), - ); - } - Widget _buildDrawer(BuildContext context, List accounts) { return Drawer( child: ListView( @@ -226,197 +187,14 @@ class _CombinedInboxScreenState extends ConsumerState { ref.read(syncManagerProvider).syncNow(a.id); } }, - child: StreamBuilder>( + child: EmailThreadList( + controller: _selection, stream: emailRepo.observeAllInboxThreads(limit: _limit), - builder: (ctx, snap) { - if (!snap.hasData) { - return const Center(child: CircularProgressIndicator()); - } - final threads = snap.data!; - _currentThreads = threads; - if (threads.isEmpty) { - return ListView( - children: const [ - SizedBox( - height: 300, - child: Center(child: Text('No emails')), - ), - ], - ); - } - return _buildThreadList(threads, accountNames, showAccount); - }, + enablePagination: true, + showAccountLabel: showAccount, + accountNames: accountNames, + onLoadMore: () => setState(() => _limit += _pageSize), ), ); } - - Widget _buildThreadList( - List threads, - Map accountNames, - bool showAccount, - ) { - final hasMore = threads.length == _limit; - return ListView.builder( - itemCount: threads.length + (hasMore ? 1 : 0), - itemBuilder: (ctx, i) { - if (i == threads.length) { - return TextButton( - onPressed: () => setState(() => _limit += _pageSize), - child: const Text('Load more'), - ); - } - final t = threads[i]; - return EmailThreadTile( - thread: t, - isSelected: _selectedThreadIds.contains(t.threadId), - isSelecting: _selecting, - showAccount: showAccount, - accountName: accountNames[t.accountId], - onTap: _selecting - ? () => _toggleThreadSelection(t) - : t.messageCount > 1 - ? () => context.push( - '/accounts/${t.accountId}/mailboxes' - '/${Uri.encodeComponent(t.mailboxPath)}' - '/threads/${Uri.encodeComponent(t.threadId)}', - ) - : () => context.push( - '/accounts/${t.accountId}/mailboxes' - '/${Uri.encodeComponent(t.mailboxPath)}' - '/emails/${Uri.encodeComponent(t.latestEmailId)}', - ), - onLongPress: () => _toggleThreadSelection(t), - onDismissed: (direction) => _onSwipeDismissed(t, direction), - ); - }, - ); - } - - Future _onSwipeDismissed( - EmailThread t, - DismissDirection direction, - ) async { - final repo = ref.read(emailRepositoryProvider); - - final originalEmails = (await Future.wait( - t.emailIds.map((id) => repo.getEmail(id)), - )) - .whereType() - .toList(); - - if (direction == DismissDirection.startToEnd) { - final archive = await ref - .read(mailboxRepositoryProvider) - .findMailboxByRole(t.accountId, 'archive'); - if (!mounted || archive == null) return; - - for (final id in t.emailIds) { - await repo.moveEmail(id, archive.path); - } - final action = UndoAction( - id: DateTime.now().toIso8601String(), - accountId: t.accountId, - type: UndoType.move, - emailIds: t.emailIds, - sourceMailboxPath: t.mailboxPath, - destinationMailboxPath: archive.path, - originalEmails: originalEmails, - ); - unawaited(ref.read(undoServiceProvider.notifier).pushAction(action)); - return; - } - - String? lastDestPath; - for (final id in t.emailIds) { - lastDestPath = await repo.deleteEmail(id); - } - final action = UndoAction( - id: DateTime.now().toIso8601String(), - accountId: t.accountId, - type: UndoType.delete, - emailIds: t.emailIds, - sourceMailboxPath: t.mailboxPath, - destinationMailboxPath: lastDestPath, - originalEmails: originalEmails, - ); - unawaited(ref.read(undoServiceProvider.notifier).pushAction(action)); - } - - Future _batchArchive() async { - final repo = ref.read(emailRepositoryProvider); - final mailboxRepo = ref.read(mailboxRepositoryProvider); - - // Group selected threads by accountId so we look up each account's archive once. - final byAccount = >{}; - for (final t in _currentThreads) { - if (!_selectedThreadIds.contains(t.threadId)) continue; - (byAccount[t.accountId] ??= []).add(t); - } - - _clearSelection(); - - for (final entry in byAccount.entries) { - final accountId = entry.key; - final threads = entry.value; - final archive = await mailboxRepo.findMailboxByRole(accountId, 'archive'); - if (!mounted || archive == null) continue; - - for (final t in threads) { - final originalEmails = (await Future.wait( - t.emailIds.map((id) => repo.getEmail(id)), - )) - .whereType() - .toList(); - - for (final id in t.emailIds) { - await repo.moveEmail(id, archive.path); - } - - final action = UndoAction( - id: DateTime.now().toIso8601String(), - accountId: accountId, - type: UndoType.move, - emailIds: t.emailIds, - sourceMailboxPath: t.mailboxPath, - destinationMailboxPath: archive.path, - originalEmails: originalEmails, - ); - unawaited(ref.read(undoServiceProvider.notifier).pushAction(action)); - } - } - } - - Future _batchDelete() async { - final repo = ref.read(emailRepositoryProvider); - - final selectedThreads = _currentThreads - .where((t) => _selectedThreadIds.contains(t.threadId)) - .toList(); - - _clearSelection(); - - for (final t in selectedThreads) { - final originalEmails = (await Future.wait( - t.emailIds.map((id) => repo.getEmail(id)), - )) - .whereType() - .toList(); - - String? lastDestPath; - for (final id in t.emailIds) { - lastDestPath = await repo.deleteEmail(id); - } - - final action = UndoAction( - id: DateTime.now().toIso8601String(), - accountId: t.accountId, - type: UndoType.delete, - emailIds: t.emailIds, - sourceMailboxPath: t.mailboxPath, - destinationMailboxPath: lastDestPath, - originalEmails: originalEmails, - ); - unawaited(ref.read(undoServiceProvider.notifier).pushAction(action)); - } - } } diff --git a/lib/ui/screens/email_action_helpers.dart b/lib/ui/screens/email_action_helpers.dart index 07b5dee..6adce3f 100644 --- a/lib/ui/screens/email_action_helpers.dart +++ b/lib/ui/screens/email_action_helpers.dart @@ -1,7 +1,16 @@ -import 'package:flutter/material.dart'; +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; + +import 'package:sharedinbox/core/models/email.dart'; import 'package:sharedinbox/core/models/mailbox.dart'; +import 'package:sharedinbox/core/models/undo_action.dart'; +import 'package:sharedinbox/core/repositories/email_repository.dart'; import 'package:sharedinbox/core/repositories/mailbox_repository.dart'; +import 'package:sharedinbox/di.dart'; +import 'package:sharedinbox/ui/widgets/snooze_picker.dart'; enum _MissingFolderChoice { chooseExisting, createNew } @@ -78,3 +87,288 @@ Future resolveMailboxByRole( return mailbox; } + +// --------------------------------------------------------------------------- +// Shared batch helpers +// --------------------------------------------------------------------------- +// +// Single source of truth for batch actions across every email-list surface +// (folder, combined inbox, search, address). Threads are grouped by +// accountId so a multi-account selection still produces correctly scoped +// repository calls and undo actions. + +/// Archives every thread in [threads], grouping by account so each account's +/// archive folder is resolved once. Prompts the user when an account has no +/// archive folder. +Future batchArchive( + BuildContext context, + WidgetRef ref, { + required List threads, +}) => + _batchMoveToRole( + context, + ref, + threads: threads, + role: 'archive', + dialogTitle: 'No archive folder found', + createFolderName: 'Archive', + ); + +/// Moves every thread in [threads] to its account's junk folder. +Future batchMarkSpam( + BuildContext context, + WidgetRef ref, { + required List threads, +}) => + _batchMoveToRole( + context, + ref, + threads: threads, + role: 'junk', + dialogTitle: 'No spam folder found', + createFolderName: 'Junk', + ); + +Future _batchMoveToRole( + BuildContext context, + WidgetRef ref, { + required List threads, + required String role, + required String dialogTitle, + required String createFolderName, +}) async { + if (threads.isEmpty) return; + final mailboxRepo = ref.read(mailboxRepositoryProvider); + + final byAccount = _groupByAccount(threads); + for (final entry in byAccount.entries) { + if (!context.mounted) return; + final accountId = entry.key; + final accountThreads = entry.value; + final mailbox = await resolveMailboxByRole( + context, + mailboxRepo, + accountId, + accountThreads.first.mailboxPath, + role, + dialogTitle: dialogTitle, + createFolderName: createFolderName, + ); + if (mailbox == null) continue; + + await _moveThreadsTo(ref, accountThreads, mailbox.path); + } +} + +/// Deletes every thread in [threads]. Each thread becomes its own undo entry +/// so the destination path remains per-thread (e.g. each account's Trash). +Future batchDelete( + WidgetRef ref, { + required List threads, +}) async { + if (threads.isEmpty) return; + final repo = ref.read(emailRepositoryProvider); + + for (final t in threads) { + final originalEmails = await _fetchOriginals(repo, t.emailIds); + + String? lastDestPath; + for (final id in t.emailIds) { + lastDestPath = await repo.deleteEmail(id); + } + + final action = UndoAction( + id: DateTime.now().toIso8601String(), + accountId: t.accountId, + type: UndoType.delete, + emailIds: t.emailIds, + sourceMailboxPath: t.mailboxPath, + destinationMailboxPath: lastDestPath, + originalEmails: originalEmails, + ); + unawaited(ref.read(undoServiceProvider.notifier).pushAction(action)); + } +} + +/// Lets the user pick a destination folder and moves every thread there. +/// Cross-account selections show one picker per account; cancelled accounts +/// are skipped. +Future batchMove( + BuildContext context, + WidgetRef ref, { + required List threads, +}) async { + if (threads.isEmpty) return; + final mailboxRepo = ref.read(mailboxRepositoryProvider); + + final byAccount = _groupByAccount(threads); + for (final entry in byAccount.entries) { + final accountId = entry.key; + final accountThreads = entry.value; + final currentPath = accountThreads.first.mailboxPath; + + final mailboxes = await mailboxRepo.observeMailboxes(accountId).first; + if (!context.mounted) return; + final destinations = mailboxes.where((m) => m.path != currentPath).toList(); + + final chosen = await showModalBottomSheet( + context: context, + builder: (ctx) => ListView( + shrinkWrap: true, + children: [ + const ListTile( + title: Text( + 'Move to…', + style: TextStyle(fontWeight: FontWeight.bold), + ), + ), + for (final m in destinations) + ListTile( + leading: const Icon(Icons.folder_outlined), + title: Text(m.name), + onTap: () => Navigator.pop(ctx, m.path), + ), + ], + ), + ); + if (chosen == null || !context.mounted) continue; + + await _moveThreadsTo(ref, accountThreads, chosen); + } +} + +Future batchSnooze( + BuildContext context, + WidgetRef ref, { + required List threads, +}) async { + if (threads.isEmpty) return; + final until = await showModalBottomSheet( + context: context, + builder: (ctx) => const SnoozePicker(), + ); + if (until == null || !context.mounted) return; + + final repo = ref.read(emailRepositoryProvider); + var totalCount = 0; + + for (final t in threads) { + final originalEmails = await _fetchOriginals(repo, t.emailIds); + + for (final id in t.emailIds) { + await repo.snoozeEmail(id, until); + } + + final action = UndoAction( + id: DateTime.now().toIso8601String(), + accountId: t.accountId, + type: UndoType.snooze, + emailIds: t.emailIds, + sourceMailboxPath: t.mailboxPath, + originalEmails: originalEmails, + ); + unawaited(ref.read(undoServiceProvider.notifier).pushAction(action)); + totalCount += t.emailIds.length; + } + + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + duration: const Duration(seconds: 5), + content: Text( + 'Snoozed $totalCount email${totalCount == 1 ? '' : 's'} until ' + '${DateFormat('MMM d, HH:mm').format(until)}', + ), + ), + ); +} + +/// Handles a swipe-to-archive (start→end) or swipe-to-delete (end→start) on a +/// single [thread]. Shared between folder and combined inbox surfaces. +Future swipeDismissThread( + WidgetRef ref, + EmailThread thread, + DismissDirection direction, +) async { + final repo = ref.read(emailRepositoryProvider); + + final originalEmails = await _fetchOriginals(repo, thread.emailIds); + + if (direction == DismissDirection.startToEnd) { + final archive = await ref + .read(mailboxRepositoryProvider) + .findMailboxByRole(thread.accountId, 'archive'); + if (archive == null) return; + for (final id in thread.emailIds) { + await repo.moveEmail(id, archive.path); + } + final action = UndoAction( + id: DateTime.now().toIso8601String(), + accountId: thread.accountId, + type: UndoType.move, + emailIds: thread.emailIds, + sourceMailboxPath: thread.mailboxPath, + destinationMailboxPath: archive.path, + originalEmails: originalEmails, + ); + unawaited(ref.read(undoServiceProvider.notifier).pushAction(action)); + return; + } + + String? lastDestPath; + for (final id in thread.emailIds) { + lastDestPath = await repo.deleteEmail(id); + } + final action = UndoAction( + id: DateTime.now().toIso8601String(), + accountId: thread.accountId, + type: UndoType.delete, + emailIds: thread.emailIds, + sourceMailboxPath: thread.mailboxPath, + destinationMailboxPath: lastDestPath, + originalEmails: originalEmails, + ); + unawaited(ref.read(undoServiceProvider.notifier).pushAction(action)); +} + +Future> _fetchOriginals( + EmailRepository repo, + Iterable ids, +) async => + (await Future.wait(ids.map((id) => repo.getEmail(id)))) + .whereType() + .toList(); + +Map> _groupByAccount(List threads) { + final byAccount = >{}; + for (final t in threads) { + (byAccount[t.accountId] ??= []).add(t); + } + return byAccount; +} + +Future _moveThreadsTo( + WidgetRef ref, + List threads, + String destPath, +) async { + final repo = ref.read(emailRepositoryProvider); + for (final t in threads) { + final originalEmails = await _fetchOriginals(repo, t.emailIds); + + for (final id in t.emailIds) { + await repo.moveEmail(id, destPath); + } + + final action = UndoAction( + id: DateTime.now().toIso8601String(), + accountId: t.accountId, + type: UndoType.move, + emailIds: t.emailIds, + sourceMailboxPath: t.mailboxPath, + destinationMailboxPath: destPath, + originalEmails: originalEmails, + ); + unawaited(ref.read(undoServiceProvider.notifier).pushAction(action)); + } +} diff --git a/lib/ui/screens/email_list_screen.dart b/lib/ui/screens/email_list_screen.dart index 5e54a7e..9b9dce4 100644 --- a/lib/ui/screens/email_list_screen.dart +++ b/lib/ui/screens/email_list_screen.dart @@ -3,19 +3,14 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -import 'package:intl/intl.dart'; import 'package:sharedinbox/core/models/account.dart'; import 'package:sharedinbox/core/models/email.dart'; -import 'package:sharedinbox/core/models/undo_action.dart'; import 'package:sharedinbox/core/models/user_preferences.dart'; import 'package:sharedinbox/core/repositories/email_repository.dart'; import 'package:sharedinbox/di.dart'; -import 'package:sharedinbox/ui/screens/email_action_helpers.dart'; -import 'package:sharedinbox/ui/widgets/email_thread_tile.dart'; +import 'package:sharedinbox/ui/widgets/email_thread_list.dart'; import 'package:sharedinbox/ui/widgets/folder_drawer.dart'; -import 'package:sharedinbox/ui/widgets/snooze_picker.dart'; -import 'package:sharedinbox/ui/widgets/thread_tile.dart'; class EmailListScreen extends ConsumerStatefulWidget { const EmailListScreen({ @@ -40,12 +35,7 @@ class _EmailListScreenState extends ConsumerState { // Error banner — tracks the last error message that the user dismissed. String? _dismissedError; - // Thread-level selection (key = threadId). - final Set _selectedThreadIds = {}; - // Last-emitted thread list, used to resolve emailIds for batch operations. - List _currentThreads = []; - // Individual email selection used in search results. - final Set _selectedSearchIds = {}; + late final EmailThreadListController _selection; // Pagination: number of threads currently requested from the DB. static const _pageSize = 50; @@ -59,12 +49,11 @@ class _EmailListScreenState extends ConsumerState { // Used to skip redundant re-runs when the user presses Enter on an // already-settled search (issue #473). String? _lastSettledQuery; - bool get _selecting => - _selectedThreadIds.isNotEmpty || _selectedSearchIds.isNotEmpty; @override void initState() { super.initState(); + _selection = EmailThreadListController()..addListener(_onSelectionChange); _searchController.addListener(() { if (_searchController.text.isEmpty) { setState(() { @@ -78,52 +67,15 @@ class _EmailListScreenState extends ConsumerState { @override void dispose() { + _selection + ..removeListener(_onSelectionChange) + ..dispose(); _searchController.dispose(); super.dispose(); } - void _toggleThreadSelection(EmailThread thread) { - setState(() { - if (_selectedThreadIds.contains(thread.threadId)) { - _selectedThreadIds.remove(thread.threadId); - } else { - _selectedThreadIds.add(thread.threadId); - } - }); - } - - void _clearSelection() => setState(() { - _selectedThreadIds.clear(); - _selectedSearchIds.clear(); - }); - - void _selectAll() { - setState(() { - if (_searching) { - _selectedSearchIds.addAll(_searchResults?.map((e) => e.id) ?? []); - } else { - _selectedThreadIds.addAll(_currentThreads.map((t) => t.threadId)); - } - }); - } - - 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 get _selectedEmailIds { - if (_searching) return _selectedSearchIds.toList(); - return _currentThreads - .where((t) => _selectedThreadIds.contains(t.threadId)) - .expand((t) => t.emailIds) - .toList(); + void _onSelectionChange() { + if (mounted) setState(() {}); } Future _runSearch(String query) async { @@ -170,17 +122,23 @@ class _EmailListScreenState extends ConsumerState { final prefs = ref.watch(userPreferencesProvider).value ?? const UserPreferences(); final menuAtBottom = prefs.menuPosition == MenuPosition.bottom; + final selecting = _selection.isSelecting; return Scaffold( appBar: _buildAppBar(repo, accountAsync, menuAtBottom: menuAtBottom), - drawer: _selecting + drawer: selecting ? null : FolderDrawer( accountId: widget.accountId, currentMailboxPath: widget.mailboxPath, ), - bottomNavigationBar: _selecting - ? _selectionBottomBar() + bottomNavigationBar: selecting + ? buildSelectionBottomBar( + context, + ref, + _selection, + onAfterAction: _onAfterBatchAction, + ) : (menuAtBottom ? _folderNavBottomBar() : null), body: Column( children: [ @@ -200,67 +158,52 @@ class _EmailListScreenState extends ConsumerState { AsyncValue accountAsync, { required bool menuAtBottom, }) { - final selectionCount = - _searching ? _selectedSearchIds.length : _selectedThreadIds.length; + if (_selection.isSelecting) { + return buildSelectionAppBar(_selection); + } return AppBar( automaticallyImplyLeading: !menuAtBottom, - leading: _selecting - ? IconButton( - icon: const Icon(Icons.close), - onPressed: _clearSelection, - ) - : null, - title: _selecting - ? Text('$selectionCount selected') - : Text(widget.mailboxPath), - actions: _selecting - ? [ - IconButton( - icon: const Icon(Icons.select_all), - tooltip: 'Select all', - onPressed: _selectAll, + title: Text(widget.mailboxPath), + actions: [ + accountAsync.when( + loading: () => const SizedBox.shrink(), + error: (_, __) => const SizedBox.shrink(), + data: (account) => Padding( + padding: const EdgeInsets.only(right: 4), + child: Center( + child: Text( + account?.displayName ?? '', + style: Theme.of(context).textTheme.bodySmall, ), - ] - : [ - accountAsync.when( - loading: () => const SizedBox.shrink(), - error: (_, __) => const SizedBox.shrink(), - data: (account) => Padding( - padding: const EdgeInsets.only(right: 4), - child: Center( - child: Text( - account?.displayName ?? '', - style: Theme.of(context).textTheme.bodySmall, - ), - ), - ), - ), - _buildSyncButton(emailRepo), - IconButton( - icon: const Icon(Icons.edit), - onPressed: () => context.push( - '/compose', - extra: {'accountId': widget.accountId}, - ), - ), - PopupMenuButton( - onSelected: (value) async { - if (value == 'mark_all_read') { - await emailRepo.markAllAsRead( - widget.accountId, - widget.mailboxPath, - ); - } - }, - itemBuilder: (_) => const [ - PopupMenuItem( - value: 'mark_all_read', - child: Text('Mark all as read'), - ), - ], - ), - ], + ), + ), + ), + _buildSyncButton(emailRepo), + IconButton( + icon: const Icon(Icons.edit), + onPressed: () => context.push( + '/compose', + extra: {'accountId': widget.accountId}, + ), + ), + PopupMenuButton( + onSelected: (value) async { + if (value == 'mark_all_read') { + await emailRepo.markAllAsRead( + widget.accountId, + widget.mailboxPath, + ); + } + }, + itemBuilder: (_) => const [ + PopupMenuItem( + value: 'mark_all_read', + child: Text('Mark all as read'), + ), + ], + ), + ], bottom: PreferredSize( preferredSize: const Size.fromHeight(60), child: Padding( @@ -269,9 +212,8 @@ class _EmailListScreenState extends ConsumerState { controller: _searchController, hintText: 'Search…', leading: const Icon(Icons.search), - enabled: !_selecting, trailing: [ - if (_searchController.text.isNotEmpty && !_selecting) + if (_searchController.text.isNotEmpty) IconButton( icon: const Icon(Icons.clear), onPressed: () => _searchController.clear(), @@ -350,41 +292,6 @@ class _EmailListScreenState extends ConsumerState { ); } - Widget _selectionBottomBar() { - return BottomAppBar( - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - IconButton( - icon: const Icon(Icons.archive), - tooltip: 'Archive', - onPressed: _batchArchive, - ), - IconButton( - icon: const Icon(Icons.delete), - tooltip: 'Delete', - onPressed: _batchDelete, - ), - IconButton( - icon: const Icon(Icons.report), - tooltip: 'Mark as spam', - onPressed: _batchMarkSpam, - ), - IconButton( - icon: const Icon(Icons.drive_file_move), - tooltip: 'Move to folder', - onPressed: _batchMove, - ), - IconButton( - icon: const Icon(Icons.access_time), - tooltip: 'Snooze', - onPressed: _batchSnooze, - ), - ], - ), - ); - } - Widget _buildSearchBody() { if (_searchLoading) { return const Center(child: CircularProgressIndicator()); @@ -395,7 +302,13 @@ class _EmailListScreenState extends ConsumerState { if (_searchResults!.isEmpty) { return const Center(child: Text('No results')); } - return _buildEmailList(_searchResults!); + final threads = _searchResults!.map(EmailThread.fromEmail).toList(); + return EmailThreadList( + controller: _selection, + items: threads, + enableSwipe: false, + onTap: (t) => unawaited(_openSearchResultAndRefresh(t.latestEmailId)), + ); } Widget _buildSyncErrorBanner() { @@ -440,100 +353,19 @@ class _EmailListScreenState extends ConsumerState { // Also wait for this specific mailbox to sync for immediate feedback. await emailRepo.syncEmails(widget.accountId, widget.mailboxPath); }, - child: StreamBuilder>( + child: EmailThreadList( + controller: _selection, stream: emailRepo.observeThreads( widget.accountId, widget.mailboxPath, limit: _limit, ), - builder: (ctx, snap) { - if (!snap.hasData) { - return const Center(child: CircularProgressIndicator()); - } - final threads = snap.data!; - _currentThreads = threads; - if (threads.isEmpty) { - return ListView( - children: const [ - SizedBox(height: 300, child: Center(child: Text('No emails'))), - ], - ); - } - return _buildThreadList(threads); - }, + enablePagination: true, + onLoadMore: () => setState(() => _limit += _pageSize), ), ); } - Future _batchMoveToRole( - String role, { - required String dialogTitle, - required String createFolderName, - }) async { - final ids = _selectedEmailIds; - _clearSelection(); - - final mailbox = await resolveMailboxByRole( - context, - ref.read(mailboxRepositoryProvider), - widget.accountId, - widget.mailboxPath, - role, - dialogTitle: dialogTitle, - createFolderName: createFolderName, - ); - - if (!mounted || mailbox == null) return; - - final repo = ref.read(emailRepositoryProvider); - - // Fetch full email data before moving so we can restore them if user clicks Undo. - final originalEmails = (await Future.wait( - ids.map((id) => repo.getEmail(id)), - )) - .whereType() - .toList(); - - for (final id in ids) { - await repo.moveEmail(id, mailbox.path); - } - - final action = UndoAction( - id: DateTime.now().toIso8601String(), - accountId: widget.accountId, - type: UndoType.move, - emailIds: ids, - sourceMailboxPath: widget.mailboxPath, - destinationMailboxPath: mailbox.path, - originalEmails: originalEmails, - ); - unawaited(ref.read(undoServiceProvider.notifier).pushAction(action)); - } - - Future _batchArchive() => _batchMoveToRole( - 'archive', - dialogTitle: 'No archive folder found', - createFolderName: 'Archive', - ); - - Future _refreshSearchAndPopIfEmpty() async { - if (!mounted || !_searching) return; - final query = _searchController.text.trim(); - final remaining = await ref - .read(emailRepositoryProvider) - .searchEmails(widget.accountId, widget.mailboxPath, query); - if (!mounted) return; - if (remaining.isEmpty) { - if (context.canPop()) { - context.pop(); - } else { - _searchController.clear(); - } - } else { - setState(() => _searchResults = remaining); - } - } - Future _openSearchResultAndRefresh(String emailId) async { await context.push( '/accounts/${widget.accountId}/mailboxes' @@ -543,279 +375,42 @@ class _EmailListScreenState extends ConsumerState { await _refreshSearchAndPopIfEmpty(); } - Future _batchDelete() async { - final ids = _selectedEmailIds; - final wasSearching = _searching; - _clearSelection(); - final repo = ref.read(emailRepositoryProvider); - - // Fetch full email data before deleting so we can restore them if user clicks Undo. - // This is especially important for IMAP where we hard-delete the row locally. - final originalEmails = (await Future.wait( - ids.map((id) => repo.getEmail(id)), - )) - .whereType() - .toList(); - - String? lastDestPath; - for (final id in ids) { - lastDestPath = await repo.deleteEmail(id); - } - - final action = UndoAction( - id: DateTime.now().toIso8601String(), - accountId: widget.accountId, - type: UndoType.delete, - emailIds: ids, - sourceMailboxPath: widget.mailboxPath, - destinationMailboxPath: lastDestPath, - originalEmails: originalEmails, - ); - unawaited(ref.read(undoServiceProvider.notifier).pushAction(action)); - - if (wasSearching && mounted) { - // Filter deleted emails out of the local results immediately. - // Calling searchEmails here would still return deleted rows because the - // delete is only enqueued — not yet applied to the local DB. - final deletedIds = ids.toSet(); - final remaining = (_searchResults ?? []) - .where((e) => !deletedIds.contains(e.id)) - .toList(); - if (remaining.isEmpty) { - if (context.canPop()) { - context.pop(); - } else { - _searchController.clear(); - } - } else { - setState(() => _searchResults = remaining); - } - } - } - - Future _batchMarkSpam() => _batchMoveToRole( - 'junk', - dialogTitle: 'No spam folder found', - createFolderName: 'Junk', - ); - - Future _batchMove() async { - final ids = _selectedEmailIds; - final mailboxes = await ref - .read(mailboxRepositoryProvider) - .observeMailboxes(widget.accountId) - .first; - final destinations = - mailboxes.where((m) => m.path != widget.mailboxPath).toList(); - + Future _refreshSearchAndPopIfEmpty() async { + if (!mounted || !_searching) return; + final query = _searchController.text.trim(); + final remaining = await ref + .read(emailRepositoryProvider) + .searchEmails(widget.accountId, widget.mailboxPath, query); if (!mounted) return; - - final chosen = await showModalBottomSheet( - context: context, - builder: (ctx) => ListView( - shrinkWrap: true, - children: [ - const ListTile( - title: Text( - 'Move to…', - style: TextStyle(fontWeight: FontWeight.bold), - ), - ), - for (final m in destinations) - ListTile( - leading: const Icon(Icons.folder_outlined), - title: Text(m.name), - onTap: () => Navigator.pop(ctx, m.path), - ), - ], - ), - ); - - if (chosen == null || !mounted) return; - _clearSelection(); - final repo = ref.read(emailRepositoryProvider); - - // Fetch full email data before moving so we can restore them if user clicks Undo. - final originalEmails = (await Future.wait( - ids.map((id) => repo.getEmail(id)), - )) - .whereType() - .toList(); - - for (final id in ids) { - await repo.moveEmail(id, chosen); - } - - final action = UndoAction( - id: DateTime.now().toIso8601String(), - accountId: widget.accountId, - type: UndoType.move, - emailIds: ids, - sourceMailboxPath: widget.mailboxPath, - destinationMailboxPath: chosen, - originalEmails: originalEmails, - ); - unawaited(ref.read(undoServiceProvider.notifier).pushAction(action)); - } - - Future _batchSnooze() async { - final ids = _selectedEmailIds; - final until = await showModalBottomSheet( - context: context, - builder: (ctx) => const SnoozePicker(), - ); - if (until == null || !mounted) return; - - _clearSelection(); - final repo = ref.read(emailRepositoryProvider); - // Fetch full email data before snoozing so we can restore them if user clicks Undo. - final originalEmails = (await Future.wait( - ids.map((id) => repo.getEmail(id)), - )) - .whereType() - .toList(); - - for (final id in ids) { - await repo.snoozeEmail(id, until); - } - - final action = UndoAction( - id: DateTime.now().toIso8601String(), - accountId: widget.accountId, - type: UndoType.snooze, - emailIds: ids, - sourceMailboxPath: widget.mailboxPath, - originalEmails: originalEmails, - ); - unawaited(ref.read(undoServiceProvider.notifier).pushAction(action)); - - if (!mounted) return; - - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - duration: const Duration(seconds: 5), - content: Text( - 'Snoozed ${ids.length} email${ids.length == 1 ? '' : 's'} until ${DateFormat('MMM d, HH:mm').format(until)}', - ), - ), - ); - } - - Widget _buildThreadList(List threads) { - final hasMore = threads.length == _limit; - return ListView.builder( - itemCount: threads.length + (hasMore ? 1 : 0), - itemBuilder: (ctx, i) { - if (i == threads.length) { - return TextButton( - onPressed: () => setState(() => _limit += _pageSize), - child: const Text('Load more'), - ); - } - final t = threads[i]; - return EmailThreadTile( - thread: t, - isSelected: _selectedThreadIds.contains(t.threadId), - isSelecting: _selecting, - onTap: _selecting - ? () => _toggleThreadSelection(t) - : t.messageCount > 1 - ? () => context.push( - '/accounts/${widget.accountId}/mailboxes' - '/${Uri.encodeComponent(widget.mailboxPath)}' - '/threads/${Uri.encodeComponent(t.threadId)}', - ) - : () => context.push( - '/accounts/${widget.accountId}/mailboxes' - '/${Uri.encodeComponent(widget.mailboxPath)}' - '/emails/${Uri.encodeComponent(t.latestEmailId)}', - ), - onLongPress: () => _toggleThreadSelection(t), - onDismissed: (direction) => _onSwipeDismissed(t, direction), - ); - }, - ); - } - - Future _onSwipeDismissed( - EmailThread t, - DismissDirection direction, - ) async { - final repo = ref.read(emailRepositoryProvider); - final type = direction == DismissDirection.startToEnd - ? UndoType.move - : UndoType.delete; - - // Fetch full email data before moving/deleting. - final originalEmails = (await Future.wait( - t.emailIds.map((id) => repo.getEmail(id)), - )) - .whereType() - .toList(); - - if (direction == DismissDirection.startToEnd) { - final archive = await ref - .read(mailboxRepositoryProvider) - .findMailboxByRole(widget.accountId, 'archive'); - if (!mounted || archive == null) return; - for (final id in t.emailIds) { - await repo.moveEmail(id, archive.path); + if (remaining.isEmpty) { + if (context.canPop()) { + context.pop(); + return; } - final action = UndoAction( - id: DateTime.now().toIso8601String(), - accountId: widget.accountId, - type: type, - emailIds: t.emailIds, - sourceMailboxPath: widget.mailboxPath, - destinationMailboxPath: archive.path, - originalEmails: originalEmails, - ); - unawaited(ref.read(undoServiceProvider.notifier).pushAction(action)); + _searchController.clear(); return; } - - String? lastDestPath; - for (final id in t.emailIds) { - lastDestPath = await repo.deleteEmail(id); - } - final action = UndoAction( - id: DateTime.now().toIso8601String(), - accountId: widget.accountId, - type: type, - emailIds: t.emailIds, - sourceMailboxPath: widget.mailboxPath, - destinationMailboxPath: lastDestPath, - originalEmails: originalEmails, - ); - unawaited(ref.read(undoServiceProvider.notifier).pushAction(action)); + setState(() => _searchResults = remaining); } - // Used for search results, which are individual emails. - Widget _buildEmailList(List emails) { - return ListView.builder( - itemCount: emails.length, - itemBuilder: (ctx, i) { - final e = emails[i]; - final t = EmailThread.fromEmail(e); - final isSelected = _selectedSearchIds.contains(e.id); - return ThreadTile( - thread: t, - selected: isSelected, - leading: SizedBox( - width: 40, - child: _selecting - ? Checkbox( - value: isSelected, - onChanged: (_) => _toggleSearchSelection(e.id), - ) - : null, - ), - onTap: _selecting - ? () => _toggleSearchSelection(e.id) - : () => unawaited(_openSearchResultAndRefresh(e.id)), - onLongPress: () => _toggleSearchSelection(e.id), - ); - }, - ); + void _onAfterBatchAction(List actedThreadIds) { + if (!_searching || !mounted) return; + + // Filter acted-on emails out of the local results immediately. Calling + // searchEmails would still return them because the delete is only + // enqueued — not yet applied to the local DB. + final actedSet = actedThreadIds.toSet(); + final remaining = (_searchResults ?? []) + .where((e) => !actedSet.contains(e.threadId ?? e.id)) + .toList(); + if (remaining.isEmpty) { + if (context.canPop()) { + context.pop(); + return; + } + _searchController.clear(); + return; + } + setState(() => _searchResults = remaining); } } diff --git a/lib/ui/widgets/email_thread_list.dart b/lib/ui/widgets/email_thread_list.dart new file mode 100644 index 0000000..cdd6886 --- /dev/null +++ b/lib/ui/widgets/email_thread_list.dart @@ -0,0 +1,432 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart' show listEquals; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import 'package:sharedinbox/core/models/email.dart'; +import 'package:sharedinbox/ui/screens/email_action_helpers.dart'; +import 'package:sharedinbox/ui/widgets/thread_tile.dart'; + +/// Controller for [EmailThreadList]. +/// +/// Holds the current selection set and the last-seen thread list, so the host +/// screen can listen for selection-mode changes (to swap AppBars, hide the +/// drawer, etc.) and read [selectedThreads] when wiring batch-action buttons. +class EmailThreadListController extends ChangeNotifier { + final Set _selected = {}; + List _threads = const []; + + /// All threads currently rendered (latest stream emission or static input). + List get visibleThreads => List.unmodifiable(_threads); + + /// Threads whose `threadId` is selected (preserving the list's order). + List get selectedThreads => + _threads.where((t) => _selected.contains(t.threadId)).toList(); + + Set get selectedIds => Set.unmodifiable(_selected); + + bool get isSelecting => _selected.isNotEmpty; + int get selectionCount => _selected.length; + + bool isSelected(EmailThread t) => _selected.contains(t.threadId); + + void toggle(EmailThread t) { + if (!_selected.add(t.threadId)) { + _selected.remove(t.threadId); + } + notifyListeners(); + } + + void clear() { + if (_selected.isEmpty) return; + _selected.clear(); + notifyListeners(); + } + + void selectAll() { + final before = _selected.length; + _selected.addAll(_threads.map((t) => t.threadId)); + if (_selected.length != before) notifyListeners(); + } + + /// Called by [EmailThreadList] whenever the visible threads change. Drops + /// any selected ids that no longer appear in the list. Hosts should not + /// call this directly. + void updateThreads(List threads) { + _threads = threads; + final visibleIds = threads.map((t) => t.threadId).toSet(); + final before = _selected.length; + _selected.retainAll(visibleIds); + if (_selected.length != before) notifyListeners(); + } +} + +/// A unified list of email threads used by folder, combined-inbox, search and +/// address-emails views. +/// +/// Renders selection-mode checkboxes, optional swipe-to-archive/delete and +/// optional pagination. Selection state lives in [controller]; the host screen +/// listens to it to swap its AppBar / BottomBar for selection-mode equivalents +/// (see [buildSelectionAppBar] / [buildSelectionBottomBar]). +/// +/// Provide exactly one of [stream] (live data) or [items] (static list, used +/// for search / by-address results). +class EmailThreadList extends ConsumerStatefulWidget { + const EmailThreadList({ + super.key, + required this.controller, + this.stream, + this.items, + this.enableSwipe = true, + this.enablePagination = false, + this.pageSize = 50, + this.showAccountLabel = false, + this.showLocationLabel = false, + this.accountNames = const {}, + this.onTap, + this.onLoadMore, + this.emptyMessage = 'No emails', + }) : assert( + (stream == null) != (items == null), + 'Provide exactly one of stream or items', + ); + + final EmailThreadListController controller; + + /// Live thread source (folder view, combined inbox). Mutually exclusive with + /// [items]. + final Stream>? stream; + + /// Static thread list (search results, by-address). Mutually exclusive with + /// [stream]. + final List? items; + + /// When true, threads can be swiped to archive (start→end) or delete + /// (end→start). Disabled for search-result lists where a swipe would + /// silently drop an item from a filtered view. + final bool enableSwipe; + + /// When true, the list shows a "Load more" button once the visible count + /// equals the current page size. + final bool enablePagination; + + /// Page size for [enablePagination]. + final int pageSize; + + /// Show an extra subtitle line with the account name (combined inbox). + /// Looked up in [accountNames] keyed by `accountId`. + final bool showAccountLabel; + final Map accountNames; + + /// Show a per-tile location label ("accountId • mailboxPath"). Used by + /// global search results. + final bool showLocationLabel; + + /// Optional tap handler. When null, the default navigates to the email or + /// thread detail route based on `messageCount`. + final ValueChanged? onTap; + + /// Notification fired when the user taps "Load more". Hosts that use a + /// stream can grow their `limit` here. + final VoidCallback? onLoadMore; + + /// Message shown when the list is empty. + final String emptyMessage; + + @override + ConsumerState createState() => _EmailThreadListState(); +} + +class _EmailThreadListState extends ConsumerState { + int _limit = 50; + + @override + void initState() { + super.initState(); + _limit = widget.pageSize; + widget.controller.addListener(_onControllerChange); + } + + @override + void didUpdateWidget(EmailThreadList oldWidget) { + super.didUpdateWidget(oldWidget); + if (!identical(oldWidget.controller, widget.controller)) { + oldWidget.controller.removeListener(_onControllerChange); + widget.controller.addListener(_onControllerChange); + } + } + + @override + void dispose() { + widget.controller.removeListener(_onControllerChange); + super.dispose(); + } + + void _onControllerChange() { + if (mounted) setState(() {}); + } + + void _publishThreads(List threads) { + if (listEquals(threads, widget.controller.visibleThreads)) return; + // Defer so we don't notifyListeners during a build phase. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) widget.controller.updateThreads(threads); + }); + } + + @override + Widget build(BuildContext context) { + if (widget.items != null) { + return _buildList(widget.items!); + } + return StreamBuilder>( + stream: widget.stream, + builder: (ctx, snap) { + if (!snap.hasData) { + return const Center(child: CircularProgressIndicator()); + } + return _buildList(snap.data!); + }, + ); + } + + Widget _buildList(List threads) { + _publishThreads(threads); + if (threads.isEmpty) { + return ListView( + children: [ + SizedBox( + height: 300, + child: Center(child: Text(widget.emptyMessage)), + ), + ], + ); + } + final hasMore = widget.enablePagination && threads.length == _limit; + return ListView.builder( + itemCount: threads.length + (hasMore ? 1 : 0), + itemBuilder: (ctx, i) { + if (i == threads.length) { + return TextButton( + onPressed: () { + setState(() => _limit += widget.pageSize); + widget.onLoadMore?.call(); + }, + child: const Text('Load more'), + ); + } + return _tileFor(threads[i]); + }, + ); + } + + Widget _tileFor(EmailThread t) { + final isSelected = widget.controller.isSelected(t); + final isSelecting = widget.controller.isSelecting; + final accountName = widget.accountNames[t.accountId]; + final locationLabel = widget.showLocationLabel + ? '${t.accountId} • ${t.mailboxPath}' + : widget.showAccountLabel + ? accountName + : null; + + final tile = ThreadTile( + thread: t, + selected: isSelected, + locationLabel: locationLabel, + leading: isSelecting + ? SizedBox( + width: 40, + child: Checkbox( + value: isSelected, + onChanged: (_) => widget.controller.toggle(t), + ), + ) + : null, + onTap: () => _onTileTap(t), + onLongPress: () => widget.controller.toggle(t), + ); + + if (!widget.enableSwipe) return tile; + + return Dismissible( + key: ValueKey('${t.accountId}:${t.threadId}'), + direction: + isSelecting ? DismissDirection.none : DismissDirection.horizontal, + background: _swipeBackground( + alignment: Alignment.centerLeft, + color: Colors.green, + icon: Icons.archive, + label: 'Archive', + ), + secondaryBackground: _swipeBackground( + alignment: Alignment.centerRight, + color: Colors.red, + icon: Icons.delete, + label: 'Delete', + ), + onDismissed: (direction) => + unawaited(swipeDismissThread(ref, t, direction)), + child: tile, + ); + } + + void _onTileTap(EmailThread t) { + if (widget.controller.isSelecting) { + widget.controller.toggle(t); + return; + } + if (widget.onTap != null) { + widget.onTap!(t); + return; + } + if (t.messageCount > 1) { + unawaited( + context.push( + '/accounts/${t.accountId}/mailboxes' + '/${Uri.encodeComponent(t.mailboxPath)}' + '/threads/${Uri.encodeComponent(t.threadId)}', + ), + ); + return; + } + unawaited( + context.push( + '/accounts/${t.accountId}/mailboxes' + '/${Uri.encodeComponent(t.mailboxPath)}' + '/emails/${Uri.encodeComponent(t.latestEmailId)}', + ), + ); + } + + static Widget _swipeBackground({ + required AlignmentGeometry alignment, + required Color color, + required IconData icon, + required String label, + }) { + return Container( + color: color, + alignment: alignment, + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: Colors.white), + const SizedBox(width: 8), + Text(label, style: const TextStyle(color: Colors.white)), + ], + ), + ); + } +} + +/// Standard "N selected" AppBar with X-close and select-all actions. +PreferredSizeWidget buildSelectionAppBar(EmailThreadListController controller) { + return AppBar( + leading: IconButton( + icon: const Icon(Icons.close), + onPressed: controller.clear, + ), + title: Text('${controller.selectionCount} selected'), + actions: [ + IconButton( + icon: const Icon(Icons.select_all), + tooltip: 'Select all', + onPressed: controller.selectAll, + ), + ], + ); +} + +/// Standard batch-action BottomAppBar. +/// +/// [onAfterAction] runs after the helper finishes and the selection is +/// cleared. It receives the list of thread IDs that were targeted so the host +/// can refresh static result lists (e.g. search results) and pop if empty. +Widget buildSelectionBottomBar( + BuildContext context, + WidgetRef ref, + EmailThreadListController controller, { + bool includeArchive = true, + bool includeDelete = true, + bool includeSpam = true, + bool includeMove = true, + bool includeSnooze = true, + void Function(List actedThreadIds)? onAfterAction, +}) { + void run(Future Function() body) { + final actedIds = controller.selectedThreads.map((t) => t.threadId).toList(); + unawaited(() async { + await body(); + controller.clear(); + onAfterAction?.call(actedIds); + }()); + } + + return BottomAppBar( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + if (includeArchive) + IconButton( + icon: const Icon(Icons.archive), + tooltip: 'Archive', + onPressed: () => run( + () => batchArchive( + context, + ref, + threads: controller.selectedThreads, + ), + ), + ), + if (includeDelete) + IconButton( + icon: const Icon(Icons.delete), + tooltip: 'Delete', + onPressed: () => run( + () => batchDelete(ref, threads: controller.selectedThreads), + ), + ), + if (includeSpam) + IconButton( + icon: const Icon(Icons.report), + tooltip: 'Mark as spam', + onPressed: () => run( + () => batchMarkSpam( + context, + ref, + threads: controller.selectedThreads, + ), + ), + ), + if (includeMove) + IconButton( + icon: const Icon(Icons.drive_file_move), + tooltip: 'Move to folder', + onPressed: () => run( + () => batchMove( + context, + ref, + threads: controller.selectedThreads, + ), + ), + ), + if (includeSnooze) + IconButton( + icon: const Icon(Icons.access_time), + tooltip: 'Snooze', + onPressed: () => run( + () => batchSnooze( + context, + ref, + threads: controller.selectedThreads, + ), + ), + ), + ], + ), + ); +} diff --git a/lib/ui/widgets/email_thread_tile.dart b/lib/ui/widgets/email_thread_tile.dart deleted file mode 100644 index 9d1023a..0000000 --- a/lib/ui/widgets/email_thread_tile.dart +++ /dev/null @@ -1,171 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:intl/intl.dart'; - -import 'package:sharedinbox/core/models/email.dart'; - -final _dateFmt = DateFormat('MMM d'); -final _formattedDates = {}; - -int _dayKey(DateTime dt) => dt.year * 10000 + dt.month * 100 + dt.day; - -String _fmtDate(DateTime dt) => - _formattedDates[_dayKey(dt)] ??= _dateFmt.format(dt); - -/// A swipeable list tile for an [EmailThread]. -/// -/// Handles the [Dismissible] wrapper (archive left, delete right) and -/// selection-mode checkbox. Pass [showAccount] to display an extra subtitle -/// line with the account name — used in the combined-inbox view. -class EmailThreadTile extends StatelessWidget { - const EmailThreadTile({ - super.key, - required this.thread, - required this.isSelected, - required this.isSelecting, - required this.onTap, - required this.onLongPress, - required this.onDismissed, - this.showAccount = false, - this.accountName, - }); - - final EmailThread thread; - final bool isSelected; - final bool isSelecting; - final VoidCallback onTap; - final VoidCallback onLongPress; - final Future Function(DismissDirection) onDismissed; - - /// When true, renders an extra subtitle line with [accountName]. - final bool showAccount; - final String? accountName; - - @override - Widget build(BuildContext context) { - final t = thread; - final senderNames = - t.participants.map((a) => a.name ?? a.email).take(3).join(', '); - - final tile = ListTile( - leading: SizedBox( - width: 40, - child: isSelecting - ? Checkbox( - value: isSelected, - onChanged: (_) => onTap(), - ) - : Icon( - t.hasUnread ? Icons.mail : Icons.mail_outline, - color: - t.hasUnread ? Theme.of(context).colorScheme.primary : null, - ), - ), - title: Row( - children: [ - Expanded( - child: Text( - senderNames.isEmpty ? '(unknown)' : senderNames, - style: t.hasUnread - ? const TextStyle(fontWeight: FontWeight.bold) - : null, - overflow: TextOverflow.ellipsis, - ), - ), - if (t.messageCount > 1) - Padding( - padding: const EdgeInsets.only(left: 4), - child: Text( - '[${t.messageCount}]', - style: Theme.of(context).textTheme.bodySmall, - ), - ), - ], - ), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - t.subject ?? '(no subject)', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: t.hasUnread - ? const TextStyle(fontWeight: FontWeight.bold) - : null, - ), - if (t.preview != null && t.preview!.isNotEmpty) - Text( - t.preview!, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall, - ), - if (showAccount && accountName != null) - Text( - accountName!, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.primary, - ), - ), - ], - ), - selected: isSelected, - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (t.isFlagged) - const Icon(Icons.star, color: Colors.amber, size: 16), - const SizedBox(width: 4), - Text( - _fmtDate(t.latestDate), - style: Theme.of(context).textTheme.bodySmall, - ), - ], - ), - onTap: onTap, - onLongPress: onLongPress, - ); - - return Dismissible( - key: ValueKey('${t.accountId}:${t.threadId}'), - direction: - isSelecting ? DismissDirection.none : DismissDirection.horizontal, - background: _swipeBackground( - alignment: Alignment.centerLeft, - color: Colors.green, - icon: Icons.archive, - label: 'Archive', - ), - secondaryBackground: _swipeBackground( - alignment: Alignment.centerRight, - color: Colors.red, - icon: Icons.delete, - label: 'Delete', - ), - onDismissed: onDismissed, - child: tile, - ); - } - - static Widget _swipeBackground({ - required AlignmentGeometry alignment, - required Color color, - required IconData icon, - required String label, - }) { - return Container( - color: color, - alignment: alignment, - padding: const EdgeInsets.symmetric(horizontal: 20), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, color: Colors.white), - const SizedBox(width: 8), - Text(label, style: const TextStyle(color: Colors.white)), - ], - ), - ); - } -} diff --git a/scripts/check_coverage.dart b/scripts/check_coverage.dart index ab5e1f1..4316a8f 100644 --- a/scripts/check_coverage.dart +++ b/scripts/check_coverage.dart @@ -84,11 +84,11 @@ const _excluded = { 'lib/data/repositories/user_preferences_repository_impl.dart', 'lib/ui/screens/user_preferences_screen.dart', 'lib/core/services/update_service.dart', - 'lib/ui/widgets/email_thread_tile.dart', 'lib/ui/screens/trusted_image_senders_screen.dart', 'lib/data/repositories/note_repository_impl.dart', 'lib/ui/widgets/filter_builder.dart', 'lib/ui/widgets/thread_tile.dart', + 'lib/ui/widgets/email_thread_list.dart', }; void main() { diff --git a/test/widget/email_thread_list_controller_test.dart b/test/widget/email_thread_list_controller_test.dart new file mode 100644 index 0000000..7945d36 --- /dev/null +++ b/test/widget/email_thread_list_controller_test.dart @@ -0,0 +1,107 @@ +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 = {}; + for (final t in ctrl.selectedThreads) { + byAccount[t.accountId] = (byAccount[t.accountId] ?? 0) + 1; + } + expect(byAccount, {'acc-1': 1, 'acc-2': 1}); + }); + }); +} diff --git a/test/widget/goldens/email_list_search_results.png b/test/widget/goldens/email_list_search_results.png index ba71341..79648f5 100644 Binary files a/test/widget/goldens/email_list_search_results.png and b/test/widget/goldens/email_list_search_results.png differ diff --git a/test/widget/goldens/email_list_selection.png b/test/widget/goldens/email_list_selection.png index 5895f8e..199da87 100644 Binary files a/test/widget/goldens/email_list_selection.png and b/test/widget/goldens/email_list_selection.png differ diff --git a/test/widget/goldens/email_list_with_emails.png b/test/widget/goldens/email_list_with_emails.png index ab55873..c32ef82 100644 Binary files a/test/widget/goldens/email_list_with_emails.png and b/test/widget/goldens/email_list_with_emails.png differ