When results were already showing for a query, pressing Enter again re-triggered an IMAP search unnecessarily. This could cause a brief loading spinner and, if the server returned results in a different order, the user would tap what looked like the first result but open the wrong email. Tracks _lastSettledQuery; _runSearch is a no-op when results are already settled for the same query. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
815 lines
25 KiB
Dart
815 lines
25 KiB
Dart
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<EmailListScreen> createState() => _EmailListScreenState();
|
|
}
|
|
|
|
class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|
final _searchController = SearchController();
|
|
List<Email>? _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<String> _selectedThreadIds = {};
|
|
// Last-emitted thread list, used to resolve emailIds for batch operations.
|
|
List<EmailThread> _currentThreads = [];
|
|
// Individual email selection used in search results.
|
|
final Set<String> _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<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 {
|
|
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 an IMAP 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<Account?> 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<String>(
|
|
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<List<EmailThread>>(
|
|
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<void> _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<Email>()
|
|
.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<void> _batchArchive() => _batchMoveToRole(
|
|
'archive',
|
|
dialogTitle: 'No archive folder found',
|
|
createFolderName: 'Archive',
|
|
);
|
|
|
|
Future<void> _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<void> _openSearchResultAndRefresh(String emailId) async {
|
|
await context.push(
|
|
'/accounts/${widget.accountId}/mailboxes'
|
|
'/${Uri.encodeComponent(widget.mailboxPath)}'
|
|
'/emails/${Uri.encodeComponent(emailId)}',
|
|
);
|
|
await _refreshSearchAndPopIfEmpty();
|
|
}
|
|
|
|
Future<void> _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<Email>()
|
|
.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 hit the IMAP server, which still has
|
|
// the emails because the delete is only enqueued — not yet applied.
|
|
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<void> _batchMarkSpam() => _batchMoveToRole(
|
|
'junk',
|
|
dialogTitle: 'No spam folder found',
|
|
createFolderName: 'Junk',
|
|
);
|
|
|
|
Future<void> _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<String>(
|
|
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<Email>()
|
|
.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<void> _batchSnooze() async {
|
|
final ids = _selectedEmailIds;
|
|
final until = await showModalBottomSheet<DateTime>(
|
|
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<Email>()
|
|
.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<EmailThread> 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<void> _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<Email>()
|
|
.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<Email> 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),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|