refactor(ui): unify email-list code across folder, combined inbox, search #557
@@ -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<AddressEmailsScreen> {
|
||||
List<Email>? _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<void> _load() async {
|
||||
final emails = await ref
|
||||
.read(emailRepositoryProvider)
|
||||
@@ -46,43 +61,35 @@ class _AddressEmailsScreenState extends ConsumerState<AddressEmailsScreen> {
|
||||
|
||||
@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<String> 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<CombinedInboxScreen> {
|
||||
static const _pageSize = 50;
|
||||
int _limit = _pageSize;
|
||||
|
||||
// Thread-level selection (key = threadId).
|
||||
final Set<String> _selectedThreadIds = {};
|
||||
// Last-emitted thread list, used to resolve emailIds for batch operations.
|
||||
List<EmailThread> _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<CombinedInboxScreen> {
|
||||
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<CombinedInboxScreen> {
|
||||
}
|
||||
|
||||
PreferredSizeWidget _buildAppBar(List<Account> 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<CombinedInboxScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
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<Account> accounts) {
|
||||
return Drawer(
|
||||
child: ListView(
|
||||
@@ -226,197 +187,14 @@ class _CombinedInboxScreenState extends ConsumerState<CombinedInboxScreen> {
|
||||
ref.read(syncManagerProvider).syncNow(a.id);
|
||||
}
|
||||
},
|
||||
child: StreamBuilder<List<EmailThread>>(
|
||||
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<EmailThread> threads,
|
||||
Map<String, String> 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<void> _onSwipeDismissed(
|
||||
EmailThread t,
|
||||
DismissDirection direction,
|
||||
) async {
|
||||
final repo = ref.read(emailRepositoryProvider);
|
||||
|
||||
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(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<void> _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 = <String, List<EmailThread>>{};
|
||||
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<Email>()
|
||||
.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<void> _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<Email>()
|
||||
.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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Mailbox?> 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<void> batchArchive(
|
||||
BuildContext context,
|
||||
WidgetRef ref, {
|
||||
required List<EmailThread> 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<void> batchMarkSpam(
|
||||
BuildContext context,
|
||||
WidgetRef ref, {
|
||||
required List<EmailThread> threads,
|
||||
}) =>
|
||||
_batchMoveToRole(
|
||||
context,
|
||||
ref,
|
||||
threads: threads,
|
||||
role: 'junk',
|
||||
dialogTitle: 'No spam folder found',
|
||||
createFolderName: 'Junk',
|
||||
);
|
||||
|
||||
Future<void> _batchMoveToRole(
|
||||
BuildContext context,
|
||||
WidgetRef ref, {
|
||||
required List<EmailThread> 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<void> batchDelete(
|
||||
WidgetRef ref, {
|
||||
required List<EmailThread> 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<void> batchMove(
|
||||
BuildContext context,
|
||||
WidgetRef ref, {
|
||||
required List<EmailThread> 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<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 || !context.mounted) continue;
|
||||
|
||||
await _moveThreadsTo(ref, accountThreads, chosen);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> batchSnooze(
|
||||
BuildContext context,
|
||||
WidgetRef ref, {
|
||||
required List<EmailThread> threads,
|
||||
}) async {
|
||||
if (threads.isEmpty) return;
|
||||
final until = await showModalBottomSheet<DateTime>(
|
||||
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<void> 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<List<Email>> _fetchOriginals(
|
||||
EmailRepository repo,
|
||||
Iterable<String> ids,
|
||||
) async =>
|
||||
(await Future.wait(ids.map((id) => repo.getEmail(id))))
|
||||
.whereType<Email>()
|
||||
.toList();
|
||||
|
||||
Map<String, List<EmailThread>> _groupByAccount(List<EmailThread> threads) {
|
||||
final byAccount = <String, List<EmailThread>>{};
|
||||
for (final t in threads) {
|
||||
(byAccount[t.accountId] ??= []).add(t);
|
||||
}
|
||||
return byAccount;
|
||||
}
|
||||
|
||||
Future<void> _moveThreadsTo(
|
||||
WidgetRef ref,
|
||||
List<EmailThread> 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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<EmailListScreen> {
|
||||
// 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 = {};
|
||||
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<EmailListScreen> {
|
||||
// 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<EmailListScreen> {
|
||||
|
||||
@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<String> 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<void> _runSearch(String query) async {
|
||||
@@ -170,17 +122,23 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
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,29 +158,14 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
AsyncValue<Account?> 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(),
|
||||
@@ -269,9 +212,8 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
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<EmailListScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
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<EmailListScreen> {
|
||||
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<EmailListScreen> {
|
||||
// Also wait for this specific mailbox to sync for immediate feedback.
|
||||
await emailRepo.syncEmails(widget.accountId, widget.mailboxPath);
|
||||
},
|
||||
child: StreamBuilder<List<EmailThread>>(
|
||||
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<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'
|
||||
@@ -543,279 +375,42 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
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);
|
||||
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();
|
||||
return;
|
||||
}
|
||||
_searchController.clear();
|
||||
return;
|
||||
}
|
||||
setState(() => _searchResults = remaining);
|
||||
}
|
||||
|
||||
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));
|
||||
void _onAfterBatchAction(List<String> actedThreadIds) {
|
||||
if (!_searching || !mounted) return;
|
||||
|
||||
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();
|
||||
// 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) => !deletedIds.contains(e.id))
|
||||
.where((e) => !actedSet.contains(e.threadId ?? 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);
|
||||
_searchController.clear();
|
||||
return;
|
||||
}
|
||||
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),
|
||||
);
|
||||
},
|
||||
);
|
||||
setState(() => _searchResults = remaining);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String> _selected = <String>{};
|
||||
List<EmailThread> _threads = const [];
|
||||
|
||||
/// All threads currently rendered (latest stream emission or static input).
|
||||
List<EmailThread> get visibleThreads => List.unmodifiable(_threads);
|
||||
|
||||
/// Threads whose `threadId` is selected (preserving the list's order).
|
||||
List<EmailThread> get selectedThreads =>
|
||||
_threads.where((t) => _selected.contains(t.threadId)).toList();
|
||||
|
||||
Set<String> 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<EmailThread> 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<List<EmailThread>>? stream;
|
||||
|
||||
/// Static thread list (search results, by-address). Mutually exclusive with
|
||||
/// [stream].
|
||||
final List<EmailThread>? 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<String, String> 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<EmailThread>? 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<EmailThreadList> createState() => _EmailThreadListState();
|
||||
}
|
||||
|
||||
class _EmailThreadListState extends ConsumerState<EmailThreadList> {
|
||||
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<EmailThread> 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<List<EmailThread>>(
|
||||
stream: widget.stream,
|
||||
builder: (ctx, snap) {
|
||||
if (!snap.hasData) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
return _buildList(snap.data!);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildList(List<EmailThread> 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<String> actedThreadIds)? onAfterAction,
|
||||
}) {
|
||||
void run(Future<void> 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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, String>{};
|
||||
|
||||
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<void> 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)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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'),
|
||||
_t('b', accountId: 'acc-2'),
|
||||
]);
|
||||
ctrl.selectAll();
|
||||
final byAccount = <String, int>{};
|
||||
for (final t in ctrl.selectedThreads) {
|
||||
byAccount[t.accountId] = (byAccount[t.accountId] ?? 0) + 1;
|
||||
}
|
||||
expect(byAccount, {'acc-1': 1, 'acc-2': 1});
|
||||
});
|
||||
});
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 62 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 59 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 89 KiB |
Reference in New Issue
Block a user