Compare commits

...
Author SHA1 Message Date
Thomas SharedInbox ccad2e4a9f chore: merge main into branch (resolve screen conflicts, add thread_tile to coverage exclusions) 2026-06-05 18:57:38 +02:00
Thomas SharedInboxandClaude Sonnet 4.6 cef63dee60 refactor: unify mail display with shared ThreadTile widget
Replace EmailTile (used only in search results) and the duplicated inline
ListTile blocks in EmailListScreen and CombinedInboxScreen with a single
ThreadTile widget. Add EmailThread.fromEmail factory so search results
(which come back as individual Email objects) can be displayed via the
same widget. Delete email_tile.dart.

Closes #429

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 17:09:38 +02:00
6 changed files with 154 additions and 15 deletions
+16
View File
@@ -192,6 +192,22 @@ class EmailThread {
required this.accountId, required this.accountId,
required this.mailboxPath, 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 { class EmailAddress {
+4 -3
View File
@@ -13,9 +13,9 @@ import 'package:sharedinbox/core/repositories/email_repository.dart';
import 'package:sharedinbox/di.dart'; import 'package:sharedinbox/di.dart';
import 'package:sharedinbox/ui/screens/email_action_helpers.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_thread_tile.dart';
import 'package:sharedinbox/ui/widgets/email_tile.dart';
import 'package:sharedinbox/ui/widgets/folder_drawer.dart'; import 'package:sharedinbox/ui/widgets/folder_drawer.dart';
import 'package:sharedinbox/ui/widgets/snooze_picker.dart'; import 'package:sharedinbox/ui/widgets/snooze_picker.dart';
import 'package:sharedinbox/ui/widgets/thread_tile.dart';
class EmailListScreen extends ConsumerStatefulWidget { class EmailListScreen extends ConsumerStatefulWidget {
const EmailListScreen({ const EmailListScreen({
@@ -762,9 +762,10 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
itemCount: emails.length, itemCount: emails.length,
itemBuilder: (ctx, i) { itemBuilder: (ctx, i) {
final e = emails[i]; final e = emails[i];
final t = EmailThread.fromEmail(e);
final isSelected = _selectedSearchIds.contains(e.id); final isSelected = _selectedSearchIds.contains(e.id);
return EmailTile( return ThreadTile(
email: e, thread: t,
selected: isSelected, selected: isSelected,
leading: SizedBox( leading: SizedBox(
width: 40, width: 40,
+4 -4
View File
@@ -8,7 +8,7 @@ import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/mailbox.dart'; import 'package:sharedinbox/core/models/mailbox.dart';
import 'package:sharedinbox/core/utils/logger.dart'; import 'package:sharedinbox/core/utils/logger.dart';
import 'package:sharedinbox/di.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<List<String>>(( final _searchHistoryProvider = FutureProvider.autoDispose<List<String>>((
ref, ref,
@@ -189,9 +189,9 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
if (r.emails.isNotEmpty) ...[ if (r.emails.isNotEmpty) ...[
const _SectionHeader('Messages'), const _SectionHeader('Messages'),
for (final e in r.emails) for (final e in r.emails)
EmailTile( ThreadTile(
email: e, thread: EmailThread.fromEmail(e),
showLocation: true, locationLabel: '${e.accountId}${e.mailboxPath}',
onTap: () => context.push( onTap: () => context.push(
'/accounts/${e.accountId}/mailboxes' '/accounts/${e.accountId}/mailboxes'
'/${Uri.encodeComponent(e.mailboxPath)}' '/${Uri.encodeComponent(e.mailboxPath)}'
+121
View File
@@ -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, String>{};
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,
);
}
}
+8 -8
View File
@@ -659,10 +659,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: meta name: meta
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.18.0" version: "1.17.0"
mime: mime:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -1088,26 +1088,26 @@ packages:
dependency: "direct dev" dependency: "direct dev"
description: description:
name: test name: test
sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20" sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.31.0" version: "1.30.0"
test_api: test_api:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.11" version: "0.7.10"
test_core: test_core:
dependency: transitive dependency: transitive
description: description:
name: test_core name: test_core
sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34" sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.6.17" version: "0.6.16"
timezone: timezone:
dependency: transitive dependency: transitive
description: description:
+1
View File
@@ -83,6 +83,7 @@ const _excluded = {
'lib/core/services/update_service.dart', 'lib/core/services/update_service.dart',
'lib/ui/widgets/email_thread_tile.dart', 'lib/ui/widgets/email_thread_tile.dart',
'lib/ui/screens/trusted_image_senders_screen.dart', 'lib/ui/screens/trusted_image_senders_screen.dart',
'lib/ui/widgets/thread_tile.dart',
}; };
void main() { void main() {