From 46f1a7fb2a8b2b3b7ba96b115a4cc685c5520712 Mon Sep 17 00:00:00 2001 From: Thomas SharedInbox Date: Thu, 4 Jun 2026 19:32:18 +0200 Subject: [PATCH] feat: add 'Create new folder' option to Move To Folder dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a 'Create new folder…' tile at the bottom of the move-to bottom sheet in the single mail view. Tapping it shows a text input dialog; on confirm, the folder is created on the server and the email is immediately moved there. - Extend MailboxRepository with createMailbox(accountId, name) - Implement it in MailboxRepositoryImpl for IMAP and JMAP, reusing existing private helpers with role made nullable - Wire up the new flow in _moveTo() on EmailDetailScreen Closes #422 Co-Authored-By: Claude Sonnet 4.6 --- lib/core/repositories/mailbox_repository.dart | 4 ++ .../repositories/mailbox_repository_impl.dart | 21 ++++++- lib/ui/screens/email_detail_screen.dart | 57 ++++++++++++++++++- 3 files changed, 77 insertions(+), 5 deletions(-) diff --git a/lib/core/repositories/mailbox_repository.dart b/lib/core/repositories/mailbox_repository.dart index 16e08de..42a06fa 100644 --- a/lib/core/repositories/mailbox_repository.dart +++ b/lib/core/repositories/mailbox_repository.dart @@ -20,4 +20,8 @@ abstract class MailboxRepository { String name, String role, ); + + /// Creates a new mailbox named [name] for [accountId] without a special role. + /// Returns the newly created [Mailbox]. + Future createMailbox(String accountId, String name); } diff --git a/lib/data/repositories/mailbox_repository_impl.dart b/lib/data/repositories/mailbox_repository_impl.dart index 00b8646..3989814 100644 --- a/lib/data/repositories/mailbox_repository_impl.dart +++ b/lib/data/repositories/mailbox_repository_impl.dart @@ -343,11 +343,23 @@ class MailboxRepositoryImpl implements MailboxRepository { } } + @override + Future createMailbox(String accountId, String name) async { + final account = (await _accounts.getAccount(accountId))!; + final password = await _accounts.getPassword(accountId); + switch (account.type) { + case account_model.AccountType.imap: + return _createMailboxWithRoleImap(account, password, name, null); + case account_model.AccountType.jmap: + return _createMailboxWithRoleJmap(account, password, name, null); + } + } + Future _createMailboxWithRoleImap( account_model.Account account, String password, String name, - String role, + String? role, ) async { final client = await _imapConnect( account, @@ -380,7 +392,7 @@ class MailboxRepositoryImpl implements MailboxRepository { account_model.Account account, String password, String name, - String role, + String? role, ) async { final jmapUrl = account.jmapUrl; if (jmapUrl == null || jmapUrl.isEmpty) { @@ -398,7 +410,10 @@ class MailboxRepositoryImpl implements MailboxRepository { { 'accountId': jmap.accountId, 'create': { - 'new-mailbox': {'name': name, 'role': role}, + 'new-mailbox': { + 'name': name, + if (role != null) 'role': role, + }, }, }, '0', diff --git a/lib/ui/screens/email_detail_screen.dart b/lib/ui/screens/email_detail_screen.dart index f424f63..102593e 100644 --- a/lib/ui/screens/email_detail_screen.dart +++ b/lib/ui/screens/email_detail_screen.dart @@ -551,6 +551,42 @@ class _EmailDetailScreenState extends ConsumerState { ); } + Future _promptNewFolderName(BuildContext context) async { + final controller = TextEditingController(); + try { + return await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Create new folder'), + content: TextField( + controller: controller, + autofocus: true, + decoration: const InputDecoration(hintText: 'Folder name'), + textCapitalization: TextCapitalization.words, + onSubmitted: (value) { + if (value.trim().isNotEmpty) Navigator.pop(ctx, value.trim()); + }, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + final name = controller.text.trim(); + if (name.isNotEmpty) Navigator.pop(ctx, name); + }, + child: const Text('Create'), + ), + ], + ), + ); + } finally { + controller.dispose(); + } + } + Future _moveTo(BuildContext context, Email header) async { final nextEmailId = await _getNextEmailIdIfNeeded(header); @@ -564,6 +600,8 @@ class _EmailDetailScreenState extends ConsumerState { if (!context.mounted) return; + const createNewSentinel = '__create_new__'; + final chosen = await showModalBottomSheet( context: context, builder: (ctx) => ListView( @@ -581,13 +619,28 @@ class _EmailDetailScreenState extends ConsumerState { title: Text(m.name), onTap: () => Navigator.pop(ctx, m.path), ), + ListTile( + leading: const Icon(Icons.create_new_folder_outlined), + title: const Text('Create new folder…'), + onTap: () => Navigator.pop(ctx, createNewSentinel), + ), ], ), ); if (chosen == null || !context.mounted) return; - await ref.read(emailRepositoryProvider).moveEmail(widget.emailId, chosen); + String destination = chosen; + if (chosen == createNewSentinel) { + final name = await _promptNewFolderName(context); + if (name == null || !context.mounted) return; + final mailbox = await mailboxRepo.createMailbox(header.accountId, name); + destination = mailbox.path; + } + + await ref + .read(emailRepositoryProvider) + .moveEmail(widget.emailId, destination); unawaited( ref.read(undoServiceProvider.notifier).pushAction( @@ -597,7 +650,7 @@ class _EmailDetailScreenState extends ConsumerState { type: UndoType.move, emailIds: [widget.emailId], sourceMailboxPath: header.mailboxPath, - destinationMailboxPath: chosen, + destinationMailboxPath: destination, ), ), );