feat: swipe to archive/delete in email list, with role-based mailbox lookup
- Add `role` field to Mailbox model (surfacing what was already stored in DB) - IMAP sync: map enough_mail special-use flags (RFC 6154) to role strings - JMAP: role was already synced to DB, now passed through _toModel() - Add MailboxRepository.findMailboxByRole() for role-based lookup - EmailListScreen: swipe right → archive, swipe left → delete (Dismissible) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
a33e794583
commit
544cd9b335
@@ -6,6 +6,9 @@ class Mailbox {
|
||||
final String name; // last path component
|
||||
final int unreadCount;
|
||||
final int totalCount;
|
||||
// JMAP role (RFC 8621) or mapped from IMAP special-use (RFC 6154).
|
||||
// e.g. "inbox", "sent", "drafts", "junk", "trash", "archive"
|
||||
final String? role;
|
||||
|
||||
const Mailbox({
|
||||
required this.id,
|
||||
@@ -14,5 +17,6 @@ class Mailbox {
|
||||
required this.name,
|
||||
required this.unreadCount,
|
||||
required this.totalCount,
|
||||
this.role,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,4 +5,7 @@ abstract class MailboxRepository {
|
||||
|
||||
/// Returns the number of mailboxes synced.
|
||||
Future<int> syncMailboxes(String accountId);
|
||||
|
||||
/// Returns the first mailbox with the given [role] for [accountId], or null.
|
||||
Future<Mailbox?> findMailboxByRole(String accountId, String role);
|
||||
}
|
||||
|
||||
@@ -37,6 +37,20 @@ class MailboxRepositoryImpl implements MailboxRepository {
|
||||
.map((rows) => rows.map(_toModel).toList());
|
||||
}
|
||||
|
||||
@override
|
||||
Future<model.Mailbox?> findMailboxByRole(
|
||||
String accountId,
|
||||
String role,
|
||||
) async {
|
||||
final row = await (_db.select(_db.mailboxes)
|
||||
..where(
|
||||
(t) => t.accountId.equals(accountId) & t.role.equals(role),
|
||||
)
|
||||
..limit(1))
|
||||
.getSingleOrNull();
|
||||
return row == null ? null : _toModel(row);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<int> syncMailboxes(String accountId) async {
|
||||
final account = (await _accounts.getAccount(accountId))!;
|
||||
@@ -84,6 +98,7 @@ class MailboxRepositoryImpl implements MailboxRepository {
|
||||
name: mb.name,
|
||||
unreadCount: Value(unread),
|
||||
totalCount: Value(total),
|
||||
role: Value(_imapRole(mb)),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -264,5 +279,17 @@ class MailboxRepositoryImpl implements MailboxRepository {
|
||||
name: row.name,
|
||||
unreadCount: row.unreadCount,
|
||||
totalCount: row.totalCount,
|
||||
role: row.role,
|
||||
);
|
||||
|
||||
/// Maps enough_mail special-use flags (RFC 6154) to JMAP role strings (RFC 8621).
|
||||
static String? _imapRole(imap.Mailbox mb) {
|
||||
if (mb.isInbox) return 'inbox';
|
||||
if (mb.isArchive) return 'archive';
|
||||
if (mb.isTrash) return 'trash';
|
||||
if (mb.isSent) return 'sent';
|
||||
if (mb.isDrafts) return 'drafts';
|
||||
if (mb.isJunk) return 'junk';
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,6 +178,24 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _archiveEmail(Email email) async {
|
||||
final mailboxRepo = ref.read(mailboxRepositoryProvider);
|
||||
final archive =
|
||||
await mailboxRepo.findMailboxByRole(widget.accountId, 'archive');
|
||||
if (!mounted) return;
|
||||
if (archive == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('No archive folder found')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
await ref.read(emailRepositoryProvider).moveEmail(email.id, archive.path);
|
||||
}
|
||||
|
||||
Future<void> _deleteEmail(Email email) async {
|
||||
await ref.read(emailRepositoryProvider).deleteEmail(email.id);
|
||||
}
|
||||
|
||||
Widget _buildList(List<Email> emails) {
|
||||
return ListView.builder(
|
||||
itemCount: emails.length,
|
||||
@@ -186,39 +204,83 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
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,
|
||||
return Dismissible(
|
||||
key: ValueKey(e.id),
|
||||
background: _swipeBackground(
|
||||
alignment: Alignment.centerLeft,
|
||||
color: Colors.green,
|
||||
icon: Icons.archive,
|
||||
label: 'Archive',
|
||||
),
|
||||
title: Text(
|
||||
sender,
|
||||
style:
|
||||
e.isSeen ? null : const TextStyle(fontWeight: FontWeight.bold),
|
||||
secondaryBackground: _swipeBackground(
|
||||
alignment: Alignment.centerRight,
|
||||
color: Colors.red,
|
||||
icon: Icons.delete,
|
||||
label: 'Delete',
|
||||
),
|
||||
subtitle: Text(
|
||||
e.subject ?? '(no subject)',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (e.isFlagged)
|
||||
const Icon(Icons.star, color: Colors.amber, size: 16),
|
||||
if (e.hasAttachment) const Icon(Icons.attach_file, size: 16),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
e.sentAt != null ? _dateFmt.format(e.sentAt!) : '',
|
||||
style: Theme.of(ctx).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: () => context.push(
|
||||
'/accounts/${widget.accountId}/mailboxes/${Uri.encodeComponent(widget.mailboxPath)}/emails/${Uri.encodeComponent(e.id)}',
|
||||
onDismissed: (direction) async {
|
||||
if (direction == DismissDirection.startToEnd) {
|
||||
await _archiveEmail(e);
|
||||
} else {
|
||||
await _deleteEmail(e);
|
||||
}
|
||||
},
|
||||
child: ListTile(
|
||||
leading: Icon(
|
||||
e.isSeen ? Icons.mail_outline : Icons.mail,
|
||||
color: e.isSeen ? null : Theme.of(ctx).colorScheme.primary,
|
||||
),
|
||||
title: Text(
|
||||
sender,
|
||||
style: e.isSeen
|
||||
? null
|
||||
: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
subtitle: Text(
|
||||
e.subject ?? '(no subject)',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (e.isFlagged)
|
||||
const Icon(Icons.star, color: Colors.amber, size: 16),
|
||||
if (e.hasAttachment) const Icon(Icons.attach_file, size: 16),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
e.sentAt != null ? _dateFmt.format(e.sentAt!) : '',
|
||||
style: Theme.of(ctx).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: () => context.push(
|
||||
'/accounts/${widget.accountId}/mailboxes/${Uri.encodeComponent(widget.mailboxPath)}/emails/${Uri.encodeComponent(e.id)}',
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
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)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,10 @@ class _FakeMailboxes implements MailboxRepository {
|
||||
|
||||
@override
|
||||
Future<int> syncMailboxes(String accountId) async => 0;
|
||||
|
||||
@override
|
||||
Future<Mailbox?> findMailboxByRole(String accountId, String role) async =>
|
||||
null;
|
||||
}
|
||||
|
||||
class _FakeEmails implements EmailRepository {
|
||||
|
||||
@@ -48,6 +48,10 @@ class FakeMailboxRepository implements MailboxRepository {
|
||||
|
||||
@override
|
||||
Future<int> syncMailboxes(String accountId) async => 0;
|
||||
|
||||
@override
|
||||
Future<Mailbox?> findMailboxByRole(String accountId, String role) async =>
|
||||
null;
|
||||
}
|
||||
|
||||
class FailingMailboxRepository implements MailboxRepository {
|
||||
@@ -57,6 +61,10 @@ class FailingMailboxRepository implements MailboxRepository {
|
||||
@override
|
||||
Future<int> syncMailboxes(String accountId) async =>
|
||||
throw Exception('simulated sync failure');
|
||||
|
||||
@override
|
||||
Future<Mailbox?> findMailboxByRole(String accountId, String role) async =>
|
||||
null;
|
||||
}
|
||||
|
||||
class FakeEmailRepository implements EmailRepository {
|
||||
@@ -170,6 +178,10 @@ class FakeMailboxRepositoryWithInbox implements MailboxRepository {
|
||||
|
||||
@override
|
||||
Future<int> syncMailboxes(String accountId) async => 0;
|
||||
|
||||
@override
|
||||
Future<Mailbox?> findMailboxByRole(String accountId, String role) async =>
|
||||
null;
|
||||
}
|
||||
|
||||
class _CountingEmailRepository extends FakeEmailRepository {
|
||||
|
||||
@@ -127,6 +127,10 @@ class FakeMailboxRepository implements MailboxRepository {
|
||||
|
||||
@override
|
||||
Future<int> syncMailboxes(String accountId) async => 0;
|
||||
|
||||
@override
|
||||
Future<Mailbox?> findMailboxByRole(String accountId, String role) async =>
|
||||
_mailboxes.where((m) => m.role == role).firstOrNull;
|
||||
}
|
||||
|
||||
class FakeEmailRepository implements EmailRepository {
|
||||
|
||||
Reference in New Issue
Block a user