Compare commits

..
Author SHA1 Message Date
Thomas SharedInbox d7baa61420 chore: merge origin/main into issue-424-unify-email-list-views 2026-06-05 18:01:00 +02:00
Thomas SharedInbox d294a0df13 ci: retrigger after Dagger engine restart 2026-06-05 17:27:29 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 4c320fe01b fix: add email_thread_tile to coverage exclusions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 17:09:59 +02:00
Thomas SharedInbox 82d184a61b Merge branch 'main' into issue-424-unify-email-list-views 2026-06-05 17:09:21 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 c0669d1015 feat: unify thread list views via shared EmailThreadTile widget
Extracts a shared EmailThreadTile widget that handles the Dismissible
swipe (archive/delete), selection-mode checkbox, and account-name
subtitle — eliminating ~150 lines of duplicated rendering code between
EmailListScreen and CombinedInboxScreen.

Also adds multi-select and batch archive/delete to CombinedInboxScreen
(long-press to start, select-all in AppBar, action bar at bottom).
Cross-account batch archive groups threads by accountId to resolve the
right archive folder per account.

SearchScreen is unchanged — it serves a different purpose (cross-entity
discovery) and already uses the shared EmailTile for individual emails.

Closes #424

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 22:33:21 +02:00
15 changed files with 5 additions and 160 deletions
-2
View File
@@ -37,8 +37,6 @@ tasks:
run: once
deps: [_nix-check]
preconditions:
- sh: '[ "$(id -u)" != "0" ]'
msg: "Do not run as root. Use the dedicated dev user (see DEVELOPMENT.md)."
- sh: test -n "${IN_NIX_SHELL}"
msg: "Not in nix dev shell. Run: nix develop"
cmds:
-1
View File
@@ -1,6 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
[ "$(id -u)" != "0" ] || { echo "ERROR: Do not run as root. See DEVELOPMENT.md."; exit 1; }
REPO_DIR="$(cd "$(dirname "$0")" && pwd)"
# Load .env into environment
@@ -20,8 +20,4 @@ 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<Mailbox> createMailbox(String accountId, String name);
}
@@ -343,23 +343,11 @@ class MailboxRepositoryImpl implements MailboxRepository {
}
}
@override
Future<model.Mailbox> 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<model.Mailbox> _createMailboxWithRoleImap(
account_model.Account account,
String password,
String name,
String? role,
String role,
) async {
final client = await _imapConnect(
account,
@@ -392,7 +380,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) {
@@ -410,10 +398,7 @@ class MailboxRepositoryImpl implements MailboxRepository {
{
'accountId': jmap.accountId,
'create': {
'new-mailbox': {
'name': name,
if (role != null) 'role': role,
},
'new-mailbox': {'name': name, 'role': role},
},
},
'0',
+2 -55
View File
@@ -563,42 +563,6 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
);
}
Future<String?> _promptNewFolderName(BuildContext context) async {
final controller = TextEditingController();
try {
return await showDialog<String>(
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<void> _moveTo(BuildContext context, Email header) async {
final nextEmailId = await _getNextEmailIdIfNeeded(header);
@@ -612,8 +576,6 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
if (!context.mounted) return;
const createNewSentinel = '__create_new__';
final chosen = await showModalBottomSheet<String>(
context: context,
builder: (ctx) => ListView(
@@ -631,28 +593,13 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
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;
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);
await ref.read(emailRepositoryProvider).moveEmail(widget.emailId, chosen);
unawaited(
ref.read(undoServiceProvider.notifier).pushAction(
@@ -662,7 +609,7 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
type: UndoType.move,
emailIds: [widget.emailId],
sourceMailboxPath: header.mailboxPath,
destinationMailboxPath: destination,
destinationMailboxPath: chosen,
),
),
);
-2
View File
@@ -1,6 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
[ "${CI:-}" = "true" ] || [ "$(id -u)" != "0" ] || { echo "ERROR: Do not run as root. See DEVELOPMENT.md."; exit 1; }
if [ -z "${SOPS_AGE_KEY:-}" ]; then
echo "Error: SOPS_AGE_KEY must be set."
@@ -51,7 +50,6 @@ export_secret "RENOVATE_FORGEJO_TOKEN"
# Setup SSH directory and keys
mkdir -p ~/.ssh
chmod 700 ~/.ssh
rm -f ~/.ssh/dagger_key
echo "$DAGGER_SSH_KEY" > ~/.ssh/dagger_key
chmod 600 ~/.ssh/dagger_key
-1
View File
@@ -7,7 +7,6 @@
# Run inside nix develop:
# stalwart-dev/integration_android_test.sh
set -Eeuo pipefail
[ "$(id -u)" != "0" ] || { echo "ERROR: Do not run as root. See DEVELOPMENT.md."; exit 1; }
_SCRIPT_START=$(date +%s%3N)
ts() { echo "[$(( $(date +%s%3N) - _SCRIPT_START ))ms] $*"; }
-1
View File
@@ -5,7 +5,6 @@
#
# Run inside nix develop: stalwart-dev/integration_ui_test.sh
set -Eeuo pipefail
[ "$(id -u)" != "0" ] || { echo "ERROR: Do not run as root. See DEVELOPMENT.md."; exit 1; }
# Timing helper: prints elapsed seconds since script start with a label.
_SCRIPT_START=$(date +%s%3N)
-1
View File
@@ -2,7 +2,6 @@
# Starts Stalwart in the background on fresh random ports, runs Flutter
# integration tests, then stops it.
set -Eeuo pipefail
[ "$(id -u)" != "0" ] || { echo "ERROR: Do not run as root. See DEVELOPMENT.md."; exit 1; }
trap 'echo "Warning: A command failed ($0:$LINENO)"; exit 3' ERR
export STALWART_USER_B="${STALWART_USER_B:-alice@example.com}"
@@ -169,15 +169,6 @@ class _FakeMailboxes implements MailboxRepository {
unreadCount: 0,
totalCount: 0,
);
@override
Future<Mailbox> createMailbox(String accountId, String name) async => Mailbox(
id: '$accountId:$name',
accountId: accountId,
path: name,
name: name,
unreadCount: 0,
totalCount: 0,
);
}
class _FakeEmails implements EmailRepository {
-9
View File
@@ -239,15 +239,6 @@ class FakeMailboxRepositoryWithInbox implements MailboxRepository {
unreadCount: 0,
totalCount: 0,
);
@override
Future<Mailbox> createMailbox(String accountId, String name) async => Mailbox(
id: '$accountId:$name',
accountId: accountId,
path: name,
name: name,
unreadCount: 0,
totalCount: 0,
);
}
class _AccountRepositoryWithMissingPlugin implements AccountRepository {
@@ -235,31 +235,6 @@ class MockMailboxRepository extends _i1.Mock implements _i8.MailboxRepository {
),
)),
) as _i5.Future<_i2.Mailbox>);
@override
_i5.Future<_i2.Mailbox> createMailbox(
String? accountId,
String? name,
) =>
(super.noSuchMethod(
Invocation.method(
#createMailbox,
[
accountId,
name,
],
),
returnValue: _i5.Future<_i2.Mailbox>.value(_FakeMailbox_0(
this,
Invocation.method(
#createMailbox,
[
accountId,
name,
],
),
)),
) as _i5.Future<_i2.Mailbox>);
}
/// A class which mocks [EmailRepository].
@@ -77,15 +77,6 @@ class _FakeMailboxes implements MailboxRepository {
unreadCount: 0,
totalCount: 0,
);
@override
Future<Mailbox> createMailbox(String accountId, String name) async => Mailbox(
id: '$accountId:$name',
accountId: accountId,
path: name,
name: name,
unreadCount: 0,
totalCount: 0,
);
}
class _FakeEmails implements EmailRepository {
-9
View File
@@ -67,15 +67,6 @@ class _FakeMailboxes implements MailboxRepository {
unreadCount: 0,
totalCount: 0,
);
@override
Future<Mailbox> createMailbox(String accountId, String name) async => Mailbox(
id: '$accountId:$name',
accountId: accountId,
path: name,
name: name,
unreadCount: 0,
totalCount: 0,
);
}
class _CountingEmails implements EmailRepository {
-14
View File
@@ -192,20 +192,6 @@ class FakeMailboxRepository implements MailboxRepository {
_mailboxes.add(mailbox);
return mailbox;
}
@override
Future<Mailbox> createMailbox(String accountId, String name) async {
final mailbox = Mailbox(
id: '$accountId:$name',
accountId: accountId,
path: name,
name: name,
unreadCount: 0,
totalCount: 0,
);
_mailboxes.add(mailbox);
return mailbox;
}
}
class FakeEmailRepository implements EmailRepository {