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', };