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/combined_inbox_screen.dart b/lib/ui/screens/combined_inbox_screen.dart index 4740647..4a674da 100644 --- a/lib/ui/screens/combined_inbox_screen.dart +++ b/lib/ui/screens/combined_inbox_screen.dart @@ -3,20 +3,13 @@ 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'; +import 'package:sharedinbox/ui/widgets/thread_tile.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); class CombinedInboxScreen extends ConsumerStatefulWidget { const CombinedInboxScreen({super.key}); @@ -218,76 +211,10 @@ class _CombinedInboxScreenState extends ConsumerState { 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, - ), - ], - ), + final tile = ThreadTile( + thread: t, + locationLabel: + showAccount ? (accountNames[t.accountId] ?? t.accountId) : null, onTap: t.messageCount > 1 ? () => context.push( '/accounts/${t.accountId}/mailboxes' diff --git a/lib/ui/screens/email_list_screen.dart b/lib/ui/screens/email_list_screen.dart index 952c7c4..8555152 100644 --- a/lib/ui/screens/email_list_screen.dart +++ b/lib/ui/screens/email_list_screen.dart @@ -12,19 +12,10 @@ 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_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'; -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({ @@ -689,10 +680,10 @@ 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( + final tile = ThreadTile( + thread: t, + selected: isSelected, leading: SizedBox( width: 40, child: _selecting @@ -700,65 +691,7 @@ class _EmailListScreenState extends ConsumerState { 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, - ), - ], + : null, ), onTap: _selecting ? () => _toggleThreadSelection(t) @@ -856,9 +789,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/email_tile.dart b/lib/ui/widgets/email_tile.dart deleted file mode 100644 index d8d5794..0000000 --- a/lib/ui/widgets/email_tile.dart +++ /dev/null @@ -1,74 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:intl/intl.dart'; - -import 'package:sharedinbox/core/models/email.dart'; - -final _dateFmt = DateFormat('MMM d'); - -/// A flat list tile for an individual [email]. -/// -/// Used in search-result lists and the per-mailbox search overlay. -/// Pass a custom [leading] widget to support selection-mode checkboxes. -class EmailTile extends StatelessWidget { - const EmailTile({ - super.key, - required this.email, - required this.onTap, - this.leading, - this.selected = false, - this.onLongPress, - this.showLocation = false, - }); - - final Email email; - final VoidCallback onTap; - final Widget? leading; - final bool selected; - final VoidCallback? onLongPress; - - /// When true, appends `accountId • mailboxPath` as a second subtitle line. - final bool showLocation; - - @override - Widget build(BuildContext context) { - final sender = email.from.isNotEmpty - ? (email.from.first.name ?? email.from.first.email) - : '(unknown)'; - final date = email.sentAt != null ? _dateFmt.format(email.sentAt!) : ''; - - return ListTile( - leading: leading ?? - Icon( - email.isSeen ? Icons.mail_outline : Icons.mail, - color: email.isSeen ? null : Theme.of(context).colorScheme.primary, - ), - title: Text( - sender, - style: - email.isSeen ? null : const TextStyle(fontWeight: FontWeight.bold), - overflow: TextOverflow.ellipsis, - ), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - email.subject ?? '(no subject)', - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - if (showLocation) - Text( - '${email.accountId} • ${email.mailboxPath}', - style: Theme.of(context).textTheme.bodySmall, - ), - ], - ), - trailing: date.isEmpty - ? null - : Text(date, style: Theme.of(context).textTheme.bodySmall), - selected: selected, - onTap: onTap, - onLongPress: onLongPress, - ); - } -} 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: