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/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({ super.key, required this.accountId, required this.mailboxPath, }); final String accountId; final String mailboxPath; @override ConsumerState createState() => _EmailListScreenState(); } class _EmailListScreenState extends ConsumerState { final _searchController = SearchController(); List? _searchResults; bool _searchLoading = false; bool get _searching => _searchController.text.isNotEmpty; // 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 = {}; // Pagination: number of threads currently requested from the DB. static const _pageSize = 50; int _limit = _pageSize; // Incremented on every search start; stale completions are ignored when the // generation has advanced (prevents out-of-order IMAP responses from // overwriting fresh results with results for an older query). int _searchGeneration = 0; // The query whose results are currently settled in _searchResults. // 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(); _searchController.addListener(() { if (_searchController.text.isEmpty) { setState(() { _searchResults = null; _searchLoading = false; _lastSettledQuery = null; }); } }); } @override void 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(); } Future _runSearch(String query) async { final q = query.trim(); if (q.isEmpty) { setState(() { _searchResults = null; _lastSettledQuery = null; }); return; } // Skip if results are already settled for this exact query — prevents the // Enter key from re-triggering a search that already completed. if (_searchResults != null && !_searchLoading && q == _lastSettledQuery) { return; } final generation = ++_searchGeneration; setState(() => _searchLoading = true); try { final results = await ref .read(emailRepositoryProvider) .searchEmails(widget.accountId, widget.mailboxPath, q); if (mounted && generation == _searchGeneration) { setState(() { _searchResults = results; _lastSettledQuery = q; }); } } finally { if (mounted && generation == _searchGeneration) { setState(() => _searchLoading = false); } } } void _onSearchChanged(String value) { if (value.trim().isNotEmpty) unawaited(_runSearch(value.trim())); } @override Widget build(BuildContext context) { final repo = ref.watch(emailRepositoryProvider); final accountAsync = ref.watch(accountByIdProvider(widget.accountId)); final prefs = ref.watch(userPreferencesProvider).value ?? const UserPreferences(); final menuAtBottom = prefs.menuPosition == MenuPosition.bottom; return Scaffold( appBar: _buildAppBar(repo, accountAsync, menuAtBottom: menuAtBottom), drawer: _selecting ? null : FolderDrawer( accountId: widget.accountId, currentMailboxPath: widget.mailboxPath, ), bottomNavigationBar: _selecting ? _selectionBottomBar() : (menuAtBottom ? _folderNavBottomBar() : null), body: Column( children: [ _buildSyncErrorBanner(), Expanded( child: (_searchResults != null || _searchLoading) ? _buildSearchBody() : _buildStreamBody(repo), ), ], ), ); } PreferredSizeWidget _buildAppBar( EmailRepository emailRepo, AsyncValue accountAsync, { required bool menuAtBottom, }) { final selectionCount = _searching ? _selectedSearchIds.length : _selectedThreadIds.length; 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, ), ] : [ 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'), ), ], ), ], 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), enabled: !_selecting, trailing: [ if (_searchController.text.isNotEmpty && !_selecting) IconButton( icon: const Icon(Icons.clear), onPressed: () => _searchController.clear(), ), ], onChanged: _onSearchChanged, onSubmitted: _runSearch, textInputAction: TextInputAction.search, ), ), ), ); } Widget _buildSyncButton(EmailRepository emailRepo) { final isSyncing = ref.watch(isSyncingProvider(widget.accountId)).value ?? false; final hasError = ref.watch(syncLastErrorProvider(widget.accountId)).value != null; return IconButton( tooltip: isSyncing ? 'Syncing…' : hasError ? 'Sync error' : 'Sync', icon: isSyncing ? const SizedBox( width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2), ) : hasError ? const Icon(Icons.sync_problem, color: Colors.red) : const Icon(Icons.sync), onPressed: isSyncing ? null : () async { try { await emailRepo.syncEmails( widget.accountId, widget.mailboxPath, ); } catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( duration: const Duration(seconds: 5), content: Text('Sync failed: $e'), ), ); } }, ); } Widget _folderNavBottomBar() { return BottomAppBar( child: Row( children: [ Builder( builder: (context) => IconButton( icon: const Icon(Icons.menu), tooltip: 'Open folders', onPressed: () => Scaffold.of(context).openDrawer(), ), ), ], ), ); } 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()); } if (_searchResults == null) { return const Center(child: Text('Type a query and press Enter')); } if (_searchResults!.isEmpty) { return const Center(child: Text('No results')); } return _buildEmailList(_searchResults!); } Widget _buildSyncErrorBanner() { final errorAsync = ref.watch(syncLastErrorProvider(widget.accountId)); final error = errorAsync.value; if (error == null || error == _dismissedError) { return const SizedBox.shrink(); } return MaterialBanner( padding: const EdgeInsets.fromLTRB(16, 8, 8, 8), content: Text(error, maxLines: 2, overflow: TextOverflow.ellipsis), leading: Icon( Icons.sync_problem, color: Theme.of(context).colorScheme.error, ), backgroundColor: Theme.of(context).colorScheme.errorContainer, actions: [ TextButton( onPressed: () { ref.read(syncManagerProvider).syncNow(widget.accountId); }, child: const Text('Retry'), ), TextButton( onPressed: () => context.push('/accounts/${widget.accountId}/sync-log'), child: const Text('View log'), ), TextButton( onPressed: () => setState(() => _dismissedError = error), child: const Text('Dismiss'), ), ], ); } Widget _buildStreamBody(EmailRepository emailRepo) { return RefreshIndicator( onRefresh: () async { // Trigger a background sync cycle immediately. ref.read(syncManagerProvider).syncNow(widget.accountId); // Also wait for this specific mailbox to sync for immediate feedback. await emailRepo.syncEmails(widget.accountId, widget.mailboxPath); }, child: StreamBuilder>( 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); }, ), ); } 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' '/${Uri.encodeComponent(widget.mailboxPath)}' '/emails/${Uri.encodeComponent(emailId)}', ); 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(); 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); } 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)); 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)); } // 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), ); }, ); } }