diff --git a/lib/core/models/email.dart b/lib/core/models/email.dart index c61e868..bcf996a 100644 --- a/lib/core/models/email.dart +++ b/lib/core/models/email.dart @@ -192,6 +192,22 @@ class EmailThread { required this.accountId, required this.mailboxPath, }); + + /// Wraps a single [Email] as a one-message thread for uniform rendering. + factory EmailThread.fromEmail(Email e) => EmailThread( + threadId: e.threadId ?? e.id, + subject: e.subject, + participants: e.from, + latestDate: e.sentAt ?? e.receivedAt, + messageCount: 1, + hasUnread: !e.isSeen, + isFlagged: e.isFlagged, + latestEmailId: e.id, + preview: e.preview, + emailIds: [e.id], + accountId: e.accountId, + mailboxPath: e.mailboxPath, + ); } class EmailAddress { diff --git a/lib/ui/screens/email_list_screen.dart b/lib/ui/screens/email_list_screen.dart index ed7a2ca..3f94f86 100644 --- a/lib/ui/screens/email_list_screen.dart +++ b/lib/ui/screens/email_list_screen.dart @@ -13,9 +13,9 @@ 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'; +import 'package:sharedinbox/ui/widgets/thread_tile.dart'; class EmailListScreen extends ConsumerStatefulWidget { const EmailListScreen({ @@ -762,9 +762,10 @@ class _EmailListScreenState extends ConsumerState { itemCount: emails.length, itemBuilder: (ctx, i) { final e = emails[i]; + final t = EmailThread.fromEmail(e); final isSelected = _selectedSearchIds.contains(e.id); - return EmailTile( - email: e, + return ThreadTile( + thread: t, selected: isSelected, leading: SizedBox( width: 40, diff --git a/lib/ui/screens/search_screen.dart b/lib/ui/screens/search_screen.dart index 903cf70..0f6e748 100644 --- a/lib/ui/screens/search_screen.dart +++ b/lib/ui/screens/search_screen.dart @@ -8,7 +8,7 @@ import 'package:sharedinbox/core/models/email.dart'; import 'package:sharedinbox/core/models/mailbox.dart'; import 'package:sharedinbox/core/utils/logger.dart'; import 'package:sharedinbox/di.dart'; -import 'package:sharedinbox/ui/widgets/email_tile.dart'; +import 'package:sharedinbox/ui/widgets/thread_tile.dart'; final _searchHistoryProvider = FutureProvider.autoDispose>(( ref, @@ -189,9 +189,9 @@ class _SearchScreenState extends ConsumerState { if (r.emails.isNotEmpty) ...[ const _SectionHeader('Messages'), for (final e in r.emails) - EmailTile( - email: e, - showLocation: true, + ThreadTile( + thread: EmailThread.fromEmail(e), + locationLabel: '${e.accountId} • ${e.mailboxPath}', onTap: () => context.push( '/accounts/${e.accountId}/mailboxes' '/${Uri.encodeComponent(e.mailboxPath)}' diff --git a/lib/ui/widgets/thread_tile.dart b/lib/ui/widgets/thread_tile.dart new file mode 100644 index 0000000..6a784c4 --- /dev/null +++ b/lib/ui/widgets/thread_tile.dart @@ -0,0 +1,121 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +import 'package:sharedinbox/core/models/email.dart'; + +final _dateFmt = DateFormat('MMM d'); +// Cache formatted dates by local calendar day to avoid repeated DateFormat.format calls. +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 list tile for an [EmailThread]. +/// +/// Used in inbox lists, combined inbox, and search result lists. +/// Pass a custom [leading] widget to support selection-mode checkboxes. +/// Pass [locationLabel] to show an extra subtitle line (e.g. account name or +/// "accountId • mailboxPath") — useful in cross-mailbox views. +class ThreadTile extends StatelessWidget { + const ThreadTile({ + super.key, + required this.thread, + required this.onTap, + this.leading, + this.selected = false, + this.onLongPress, + this.locationLabel, + }); + + final EmailThread thread; + final VoidCallback onTap; + final Widget? leading; + final bool selected; + final VoidCallback? onLongPress; + + /// When non-null, appended as an extra subtitle line in primary colour. + final String? locationLabel; + + @override + Widget build(BuildContext context) { + final senderNames = thread.participants.isEmpty + ? '(unknown)' + : thread.participants.map((a) => a.name ?? a.email).take(3).join(', '); + + return ListTile( + leading: leading ?? + Icon( + thread.hasUnread ? Icons.mail : Icons.mail_outline, + color: + thread.hasUnread ? Theme.of(context).colorScheme.primary : null, + ), + title: Row( + children: [ + Expanded( + child: Text( + senderNames, + style: thread.hasUnread + ? const TextStyle(fontWeight: FontWeight.bold) + : null, + overflow: TextOverflow.ellipsis, + ), + ), + if (thread.messageCount > 1) + Padding( + padding: const EdgeInsets.only(left: 4), + child: Text( + '[${thread.messageCount}]', + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ], + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + thread.subject ?? '(no subject)', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: thread.hasUnread + ? const TextStyle(fontWeight: FontWeight.bold) + : null, + ), + if (thread.preview != null && thread.preview!.isNotEmpty) + Text( + thread.preview!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall, + ), + if (locationLabel != null) + Text( + locationLabel!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.primary, + ), + ), + ], + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (thread.isFlagged) + const Icon(Icons.star, color: Colors.amber, size: 16), + const SizedBox(width: 4), + Text( + _fmtDate(thread.latestDate), + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + selected: selected, + onTap: onTap, + onLongPress: onLongPress, + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 17e8bbd..83fbe88 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -659,10 +659,10 @@ packages: dependency: transitive description: name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.17.0" mime: dependency: "direct main" description: @@ -1088,26 +1088,26 @@ packages: dependency: "direct dev" description: name: test - sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20" + sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7" url: "https://pub.dev" source: hosted - version: "1.31.0" + version: "1.30.0" test_api: dependency: transitive description: name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.10" test_core: dependency: transitive description: name: test_core - sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34" + sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51" url: "https://pub.dev" source: hosted - version: "0.6.17" + version: "0.6.16" timezone: dependency: transitive description: diff --git a/scripts/check_coverage.dart b/scripts/check_coverage.dart index 2e342bc..7fb625a 100644 --- a/scripts/check_coverage.dart +++ b/scripts/check_coverage.dart @@ -83,6 +83,7 @@ const _excluded = { 'lib/core/services/update_service.dart', 'lib/ui/widgets/email_thread_tile.dart', 'lib/ui/screens/trusted_image_senders_screen.dart', + 'lib/ui/widgets/thread_tile.dart', }; void main() {