From 2788a43dda2d4f75c7dd704502e6976fbc8c0022 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bot=20of=20Thomas=20G=C3=BCttler?= Date: Fri, 5 Jun 2026 17:53:48 +0200 Subject: [PATCH 1/4] feat: dedicated page for allowed image-sender addresses (#420) --- lib/ui/router.dart | 7 +++ lib/ui/screens/email_detail_screen.dart | 7 ++- lib/ui/screens/thread_detail_screen.dart | 7 ++- .../screens/trusted_image_senders_screen.dart | 63 +++++++++++++++++++ lib/ui/screens/user_preferences_screen.dart | 41 +++--------- scripts/check_coverage.dart | 1 + 6 files changed, 90 insertions(+), 36 deletions(-) create mode 100644 lib/ui/screens/trusted_image_senders_screen.dart diff --git a/lib/ui/router.dart b/lib/ui/router.dart index caff49a..9b506df 100644 --- a/lib/ui/router.dart +++ b/lib/ui/router.dart @@ -21,6 +21,7 @@ import 'package:sharedinbox/ui/screens/sieve_script_edit_screen.dart'; import 'package:sharedinbox/ui/screens/sieve_scripts_screen.dart'; import 'package:sharedinbox/ui/screens/sync_log_screen.dart'; import 'package:sharedinbox/ui/screens/thread_detail_screen.dart'; +import 'package:sharedinbox/ui/screens/trusted_image_senders_screen.dart'; import 'package:sharedinbox/ui/screens/undo_log_screen.dart'; import 'package:sharedinbox/ui/screens/user_preferences_screen.dart'; import 'package:sharedinbox/ui/widgets/undo_shell.dart'; @@ -67,6 +68,12 @@ final router = GoRouter( path: 'preferences', builder: (ctx, state) => const UserPreferencesScreen(), ), + GoRoute( + path: 'trusted-senders', + builder: (ctx, state) => TrustedImageSendersScreen( + highlightedSender: state.extra as String?, + ), + ), GoRoute( path: ':accountId/edit', builder: (ctx, state) => EditAccountScreen( diff --git a/lib/ui/screens/email_detail_screen.dart b/lib/ui/screens/email_detail_screen.dart index d3589a9..2e7240a 100644 --- a/lib/ui/screens/email_detail_screen.dart +++ b/lib/ui/screens/email_detail_screen.dart @@ -229,11 +229,14 @@ class _EmailDetailScreenState extends ConsumerState { 'Images will be loaded automatically for this sender.', ), action: SnackBarAction( - label: 'Settings', + label: 'View', onPressed: () { if (mounted) { unawaited( - context.push('/accounts/preferences'), + context.push( + '/accounts/trusted-senders', + extra: senderEmail, + ), ); } }, diff --git a/lib/ui/screens/thread_detail_screen.dart b/lib/ui/screens/thread_detail_screen.dart index ef59980..905dc57 100644 --- a/lib/ui/screens/thread_detail_screen.dart +++ b/lib/ui/screens/thread_detail_screen.dart @@ -217,11 +217,14 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> { 'Images will be loaded automatically for this sender.', ), action: SnackBarAction( - label: 'Settings', + label: 'View', onPressed: () { if (mounted) { unawaited( - context.push('/accounts/preferences'), + context.push( + '/accounts/trusted-senders', + extra: senderEmail, + ), ); } }, diff --git a/lib/ui/screens/trusted_image_senders_screen.dart b/lib/ui/screens/trusted_image_senders_screen.dart new file mode 100644 index 0000000..80d6e30 --- /dev/null +++ b/lib/ui/screens/trusted_image_senders_screen.dart @@ -0,0 +1,63 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:sharedinbox/di.dart'; + +class TrustedImageSendersScreen extends ConsumerWidget { + const TrustedImageSendersScreen({super.key, this.highlightedSender}); + + final String? highlightedSender; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final trustedSendersAsync = ref.watch(trustedImageSendersProvider); + + return Scaffold( + appBar: AppBar(title: const Text('Allowed addresses for images')), + body: trustedSendersAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (_, __) => + const Center(child: Text('Error loading trusted senders')), + data: (senders) { + if (senders.isEmpty) { + return const Padding( + padding: EdgeInsets.all(16), + child: Text( + 'No addresses added yet. ' + 'Tap "Load remote images" in an email to add the sender.', + ), + ); + } + return ListView.builder( + itemCount: senders.length, + itemBuilder: (context, index) { + final sender = senders[index]; + final isHighlighted = sender == highlightedSender; + return ListTile( + title: Text( + sender, + style: isHighlighted + ? const TextStyle(fontWeight: FontWeight.bold) + : null, + ), + trailing: IconButton( + icon: const Icon(Icons.delete_outline), + tooltip: 'Remove', + onPressed: () { + unawaited( + ref + .read(userPreferencesRepositoryProvider) + .removeTrustedImageSender(sender), + ); + }, + ), + ); + }, + ); + }, + ), + ); + } +} diff --git a/lib/ui/screens/user_preferences_screen.dart b/lib/ui/screens/user_preferences_screen.dart index 2d960e4..b42aee2 100644 --- a/lib/ui/screens/user_preferences_screen.dart +++ b/lib/ui/screens/user_preferences_screen.dart @@ -2,6 +2,7 @@ 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/user_preferences.dart'; import 'package:sharedinbox/core/sync/background_sync.dart'; @@ -14,6 +15,7 @@ class UserPreferencesScreen extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final prefsAsync = ref.watch(userPreferencesProvider); final trustedSendersAsync = ref.watch(trustedImageSendersProvider); + final trustedCount = trustedSendersAsync.value?.length ?? 0; return Scaffold( appBar: AppBar(title: const Text('Preferences')), @@ -213,41 +215,16 @@ class UserPreferencesScreen extends ConsumerWidget { const Divider(), ListTile( title: Text( - 'Trusted image senders', + 'Allowed addresses for images', style: Theme.of(context).textTheme.titleSmall, ), - subtitle: const Text( - 'Remote images are loaded automatically for these senders.', + subtitle: Text( + trustedCount == 0 + ? 'No addresses added yet.' + : '$trustedCount address${trustedCount == 1 ? '' : 'es'}', ), - ), - ...trustedSendersAsync.when( - loading: () => const [], - error: (_, __) => const [], - data: (senders) => senders.isEmpty - ? [ - const Padding( - padding: - EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Text('No trusted senders yet.'), - ), - ] - : [ - for (final sender in senders) - ListTile( - title: Text(sender), - trailing: IconButton( - icon: const Icon(Icons.delete_outline), - tooltip: 'Remove', - onPressed: () { - unawaited( - ref - .read(userPreferencesRepositoryProvider) - .removeTrustedImageSender(sender), - ); - }, - ), - ), - ], + trailing: const Icon(Icons.chevron_right), + onTap: () => context.push('/accounts/trusted-senders'), ), ], ), diff --git a/scripts/check_coverage.dart b/scripts/check_coverage.dart index c1a76de..924a0a0 100644 --- a/scripts/check_coverage.dart +++ b/scripts/check_coverage.dart @@ -81,6 +81,7 @@ 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/screens/trusted_image_senders_screen.dart', }; void main() { From adef2e9f80c8db756d553520a61eef71deb9b7c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bot=20of=20Thomas=20G=C3=BCttler?= Date: Fri, 5 Jun 2026 18:11:28 +0200 Subject: [PATCH 2/4] feat: unify thread list views via shared EmailThreadTile widget (#431) --- lib/ui/screens/combined_inbox_screen.dart | 313 ++++++++++++---------- lib/ui/screens/email_list_screen.dart | 245 +++++------------ lib/ui/widgets/email_thread_tile.dart | 171 ++++++++++++ scripts/check_coverage.dart | 1 + 4 files changed, 408 insertions(+), 322 deletions(-) create mode 100644 lib/ui/widgets/email_thread_tile.dart diff --git a/lib/ui/screens/combined_inbox_screen.dart b/lib/ui/screens/combined_inbox_screen.dart index 4740647..6f2c976 100644 --- a/lib/ui/screens/combined_inbox_screen.dart +++ b/lib/ui/screens/combined_inbox_screen.dart @@ -3,20 +3,12 @@ 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/di.dart'; - -final _dateFmt = DateFormat('MMM d'); -final _formattedDates = {}; - -int _dayKey(DateTime dt) => dt.year * 10000 + dt.month * 100 + dt.day; - -String _fmtDate(DateTime dt) => - _formattedDates[_dayKey(dt)] ??= _dateFmt.format(dt); +import 'package:sharedinbox/ui/widgets/email_thread_tile.dart'; class CombinedInboxScreen extends ConsumerStatefulWidget { const CombinedInboxScreen({super.key}); @@ -30,6 +22,31 @@ class _CombinedInboxScreenState extends ConsumerState { static const _pageSize = 50; int _limit = _pageSize; + // Thread-level selection (key = threadId). + final Set _selectedThreadIds = {}; + // Last-emitted thread list, used to resolve emailIds for batch operations. + List _currentThreads = []; + + bool get _selecting => _selectedThreadIds.isNotEmpty; + + void _toggleThreadSelection(EmailThread thread) { + setState(() { + if (_selectedThreadIds.contains(thread.threadId)) { + _selectedThreadIds.remove(thread.threadId); + } else { + _selectedThreadIds.add(thread.threadId); + } + }); + } + + void _clearSelection() => setState(() => _selectedThreadIds.clear()); + + void _selectAll() { + setState( + () => _selectedThreadIds.addAll(_currentThreads.map((t) => t.threadId)), + ); + } + @override Widget build(BuildContext context) { final accountsAsync = ref.watch(allAccountsProvider); @@ -58,18 +75,38 @@ class _CombinedInboxScreenState extends ConsumerState { return Scaffold( appBar: _buildAppBar(accounts), - drawer: _buildDrawer(context, accounts), + drawer: _selecting ? null : _buildDrawer(context, accounts), + bottomNavigationBar: _selecting ? _selectionBottomBar() : null, body: _buildBody(accountNames, showAccount), - floatingActionButton: FloatingActionButton( - onPressed: () => context.push('/compose'), - child: const Icon(Icons.edit), - ), + floatingActionButton: _selecting + ? null + : FloatingActionButton( + onPressed: () => context.push('/compose'), + child: const Icon(Icons.edit), + ), ); }, ); } PreferredSizeWidget _buildAppBar(List 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: [ @@ -91,6 +128,26 @@ class _CombinedInboxScreenState extends ConsumerState { ); } + 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 accounts) { return Drawer( child: ListView( @@ -176,6 +233,7 @@ class _CombinedInboxScreenState extends ConsumerState { return const Center(child: CircularProgressIndicator()); } final threads = snap.data!; + _currentThreads = threads; if (threads.isEmpty) { return ListView( children: const [ @@ -207,119 +265,33 @@ class _CombinedInboxScreenState extends ConsumerState { child: const Text('Load more'), ); } - return _buildThreadTile(ctx, threads[i], accountNames, showAccount); + 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), + ); }, ); } - Widget _buildThreadTile( - BuildContext ctx, - EmailThread t, - Map accountNames, - bool showAccount, - ) { - final senderNames = - t.participants.map((a) => a.name ?? a.email).take(3).join(', '); - - final tile = ListTile( - leading: Icon( - t.hasUnread ? Icons.mail : Icons.mail_outline, - color: t.hasUnread ? Theme.of(ctx).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(ctx).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(ctx).textTheme.bodySmall, - ), - if (showAccount) - Text( - accountNames[t.accountId] ?? t.accountId, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(ctx).textTheme.bodySmall?.copyWith( - color: Theme.of(ctx).colorScheme.primary, - ), - ), - ], - ), - 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(ctx).textTheme.bodySmall, - ), - ], - ), - onTap: 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)}', - ), - ); - - return Dismissible( - key: ValueKey('${t.accountId}:${t.threadId}'), - 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(_onSwipeDismissed(t, direction)), - child: tile, - ); - } - Future _onSwipeDismissed( EmailThread t, DismissDirection direction, @@ -370,24 +342,81 @@ class _CombinedInboxScreenState extends ConsumerState { unawaited(ref.read(undoServiceProvider.notifier).pushAction(action)); } - 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)), - ], - ), - ); + Future _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 = >{}; + 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() + .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 _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() + .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)); + } } } diff --git a/lib/ui/screens/email_list_screen.dart b/lib/ui/screens/email_list_screen.dart index 952c7c4..ed7a2ca 100644 --- a/lib/ui/screens/email_list_screen.dart +++ b/lib/ui/screens/email_list_screen.dart @@ -12,20 +12,11 @@ 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_tile.dart'; import 'package:sharedinbox/ui/widgets/folder_drawer.dart'; import 'package:sharedinbox/ui/widgets/snooze_picker.dart'; -final _dateFmt = DateFormat('MMM d'); -// Cache formatted dates by local calendar day so DateFormat.format is called -// at most once per unique date rather than once per list item per rebuild. -final _formattedDates = {}; - -int _dayKey(DateTime dt) => dt.year * 10000 + dt.month * 100 + dt.day; - -String _fmtDate(DateTime dt) => - _formattedDates[_dayKey(dt)] ??= _dateFmt.format(dt); - class EmailListScreen extends ConsumerStatefulWidget { const EmailListScreen({ super.key, @@ -688,168 +679,83 @@ class _EmailListScreenState extends ConsumerState { ); } final t = threads[i]; - final isSelected = _selectedThreadIds.contains(t.threadId); - final senderNames = - t.participants.map((a) => a.name ?? a.email).take(3).join(', '); - - final tile = ListTile( - leading: SizedBox( - width: 40, - child: _selecting - ? Checkbox( - value: isSelected, - onChanged: (_) => _toggleThreadSelection(t), - ) - : Icon( - t.hasUnread ? Icons.mail : Icons.mail_outline, - color: - t.hasUnread ? Theme.of(ctx).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(ctx).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(ctx).textTheme.bodySmall, - ), - ], - ), - 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(ctx).textTheme.bodySmall, - ), - ], - ), + 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)}', + '/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)}', + '/accounts/${widget.accountId}/mailboxes' + '/${Uri.encodeComponent(widget.mailboxPath)}' + '/emails/${Uri.encodeComponent(t.latestEmailId)}', ), onLongPress: () => _toggleThreadSelection(t), - ); - - // For swipe actions on threads, operate on the latest email only - // (single-email threads) or the whole thread. - return Dismissible( - key: ValueKey(t.threadId), - direction: - _selecting ? 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) 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() - .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), - ); - } else { - String? lastDestPath; - for (final id in t.emailIds) { - lastDestPath = await repo.deleteEmail(id); - } - - final action = UndoAction( - id: DateTime.now().toIso8601String(), - accountId: widget.accountId, - type: type, - emailIds: t.emailIds, - sourceMailboxPath: widget.mailboxPath, - destinationMailboxPath: lastDestPath, - originalEmails: originalEmails, - ); - unawaited( - ref.read(undoServiceProvider.notifier).pushAction(action), - ); - } - }, - child: tile, + onDismissed: (direction) => _onSwipeDismissed(t, direction), ); }, ); } + Future _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() + .toList(); + + if (direction == DismissDirection.startToEnd) { + final archive = await ref + .read(mailboxRepositoryProvider) + .findMailboxByRole(widget.accountId, 'archive'); + if (!mounted || archive == null) return; + for (final id in t.emailIds) { + await repo.moveEmail(id, archive.path); + } + final action = UndoAction( + id: DateTime.now().toIso8601String(), + accountId: widget.accountId, + type: type, + emailIds: t.emailIds, + sourceMailboxPath: widget.mailboxPath, + destinationMailboxPath: archive.path, + originalEmails: originalEmails, + ); + unawaited(ref.read(undoServiceProvider.notifier).pushAction(action)); + return; + } + + String? lastDestPath; + for (final id in t.emailIds) { + lastDestPath = await repo.deleteEmail(id); + } + final action = UndoAction( + id: DateTime.now().toIso8601String(), + accountId: widget.accountId, + type: type, + emailIds: t.emailIds, + sourceMailboxPath: widget.mailboxPath, + destinationMailboxPath: lastDestPath, + originalEmails: originalEmails, + ); + unawaited(ref.read(undoServiceProvider.notifier).pushAction(action)); + } + // Used for search results, which are individual emails. Widget _buildEmailList(List emails) { return ListView.builder( @@ -877,25 +783,4 @@ class _EmailListScreenState extends ConsumerState { }, ); } - - 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)), - ], - ), - ); - } } diff --git a/lib/ui/widgets/email_thread_tile.dart b/lib/ui/widgets/email_thread_tile.dart new file mode 100644 index 0000000..9d1023a --- /dev/null +++ b/lib/ui/widgets/email_thread_tile.dart @@ -0,0 +1,171 @@ +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 _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 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)), + ], + ), + ); + } +} diff --git a/scripts/check_coverage.dart b/scripts/check_coverage.dart index 924a0a0..2e342bc 100644 --- a/scripts/check_coverage.dart +++ b/scripts/check_coverage.dart @@ -81,6 +81,7 @@ 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', }; From 9ca7089c5073421350fdc851c5d26a1aa793ca66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bot=20of=20Thomas=20G=C3=BCttler?= Date: Fri, 5 Jun 2026 18:41:36 +0200 Subject: [PATCH 3/4] fix: enforce non-root execution in Taskfile and shell scripts (#433) --- Taskfile.yml | 2 ++ deploy.sh | 1 + scripts/setup_dagger_remote.sh | 2 ++ stalwart-dev/integration_android_test.sh | 1 + stalwart-dev/integration_ui_test.sh | 1 + stalwart-dev/test.sh | 1 + 6 files changed, 8 insertions(+) diff --git a/Taskfile.yml b/Taskfile.yml index 2d17c43..8589cb6 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -37,6 +37,8 @@ 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: diff --git a/deploy.sh b/deploy.sh index c64e603..ba82019 100755 --- a/deploy.sh +++ b/deploy.sh @@ -1,5 +1,6 @@ #!/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 diff --git a/scripts/setup_dagger_remote.sh b/scripts/setup_dagger_remote.sh index 7a3f41a..02259f8 100755 --- a/scripts/setup_dagger_remote.sh +++ b/scripts/setup_dagger_remote.sh @@ -1,5 +1,6 @@ #!/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." @@ -50,6 +51,7 @@ 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 diff --git a/stalwart-dev/integration_android_test.sh b/stalwart-dev/integration_android_test.sh index a1c9847..22d6941 100755 --- a/stalwart-dev/integration_android_test.sh +++ b/stalwart-dev/integration_android_test.sh @@ -7,6 +7,7 @@ # 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] $*"; } diff --git a/stalwart-dev/integration_ui_test.sh b/stalwart-dev/integration_ui_test.sh index b287ea0..514616a 100755 --- a/stalwart-dev/integration_ui_test.sh +++ b/stalwart-dev/integration_ui_test.sh @@ -5,6 +5,7 @@ # # 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) diff --git a/stalwart-dev/test.sh b/stalwart-dev/test.sh index 6170974..db82835 100755 --- a/stalwart-dev/test.sh +++ b/stalwart-dev/test.sh @@ -2,6 +2,7 @@ # 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}" From 3bd404f0cfcece202c99fdada15f9cdc2d3edc75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bot=20of=20Thomas=20G=C3=BCttler?= Date: Fri, 5 Jun 2026 18:53:36 +0200 Subject: [PATCH 4/4] feat: add 'Create new folder' option to Move To Folder dialog (#423) --- lib/core/repositories/mailbox_repository.dart | 4 ++ .../repositories/mailbox_repository_impl.dart | 21 ++++++- lib/ui/screens/email_detail_screen.dart | 57 ++++++++++++++++++- test/backend/account_sync_manager_test.dart | 9 +++ test/unit/account_sync_manager_test.dart | 9 +++ .../unit/account_sync_manager_test.mocks.dart | 25 ++++++++ .../reliability_runner_check_now_test.dart | 9 +++ test/unit/reliability_runner_test.dart | 9 +++ test/widget/helpers.dart | 14 +++++ 9 files changed, 152 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..acbb420 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 2e7240a..d37afb8 100644 --- a/lib/ui/screens/email_detail_screen.dart +++ b/lib/ui/screens/email_detail_screen.dart @@ -563,6 +563,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); @@ -576,6 +612,8 @@ class _EmailDetailScreenState extends ConsumerState { if (!context.mounted) return; + const createNewSentinel = '__create_new__'; + final chosen = await showModalBottomSheet( context: context, builder: (ctx) => ListView( @@ -593,13 +631,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( @@ -609,7 +662,7 @@ class _EmailDetailScreenState extends ConsumerState { type: UndoType.move, emailIds: [widget.emailId], sourceMailboxPath: header.mailboxPath, - destinationMailboxPath: chosen, + destinationMailboxPath: destination, ), ), ); diff --git a/test/backend/account_sync_manager_test.dart b/test/backend/account_sync_manager_test.dart index 4aafb9c..8d63b2b 100644 --- a/test/backend/account_sync_manager_test.dart +++ b/test/backend/account_sync_manager_test.dart @@ -169,6 +169,15 @@ class _FakeMailboxes implements MailboxRepository { unreadCount: 0, totalCount: 0, ); + @override + Future createMailbox(String accountId, String name) async => Mailbox( + id: '$accountId:$name', + accountId: accountId, + path: name, + name: name, + unreadCount: 0, + totalCount: 0, + ); } class _FakeEmails implements EmailRepository { diff --git a/test/unit/account_sync_manager_test.dart b/test/unit/account_sync_manager_test.dart index 1b17daa..c8d4261 100644 --- a/test/unit/account_sync_manager_test.dart +++ b/test/unit/account_sync_manager_test.dart @@ -239,6 +239,15 @@ class FakeMailboxRepositoryWithInbox implements MailboxRepository { unreadCount: 0, totalCount: 0, ); + @override + Future createMailbox(String accountId, String name) async => Mailbox( + id: '$accountId:$name', + accountId: accountId, + path: name, + name: name, + unreadCount: 0, + totalCount: 0, + ); } class _AccountRepositoryWithMissingPlugin implements AccountRepository { diff --git a/test/unit/account_sync_manager_test.mocks.dart b/test/unit/account_sync_manager_test.mocks.dart index 481ba08..100fc60 100644 --- a/test/unit/account_sync_manager_test.mocks.dart +++ b/test/unit/account_sync_manager_test.mocks.dart @@ -235,6 +235,31 @@ 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]. diff --git a/test/unit/reliability_runner_check_now_test.dart b/test/unit/reliability_runner_check_now_test.dart index 86fe5af..899cb32 100644 --- a/test/unit/reliability_runner_check_now_test.dart +++ b/test/unit/reliability_runner_check_now_test.dart @@ -77,6 +77,15 @@ class _FakeMailboxes implements MailboxRepository { unreadCount: 0, totalCount: 0, ); + @override + Future createMailbox(String accountId, String name) async => Mailbox( + id: '$accountId:$name', + accountId: accountId, + path: name, + name: name, + unreadCount: 0, + totalCount: 0, + ); } class _FakeEmails implements EmailRepository { diff --git a/test/unit/reliability_runner_test.dart b/test/unit/reliability_runner_test.dart index f7a8b03..180ab39 100644 --- a/test/unit/reliability_runner_test.dart +++ b/test/unit/reliability_runner_test.dart @@ -67,6 +67,15 @@ class _FakeMailboxes implements MailboxRepository { unreadCount: 0, totalCount: 0, ); + @override + Future createMailbox(String accountId, String name) async => Mailbox( + id: '$accountId:$name', + accountId: accountId, + path: name, + name: name, + unreadCount: 0, + totalCount: 0, + ); } class _CountingEmails implements EmailRepository { diff --git a/test/widget/helpers.dart b/test/widget/helpers.dart index 64acf9d..bd90316 100644 --- a/test/widget/helpers.dart +++ b/test/widget/helpers.dart @@ -192,6 +192,20 @@ class FakeMailboxRepository implements MailboxRepository { _mailboxes.add(mailbox); return mailbox; } + + @override + Future 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 {