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>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
71dac3cbb2
commit
cef63dee60
@@ -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 {
|
||||
|
||||
@@ -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, String>{};
|
||||
|
||||
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<CombinedInboxScreen> {
|
||||
Map<String, String> 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'
|
||||
|
||||
@@ -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, String>{};
|
||||
|
||||
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<EmailListScreen> {
|
||||
}
|
||||
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<EmailListScreen> {
|
||||
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<EmailListScreen> {
|
||||
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,
|
||||
|
||||
@@ -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<List<String>>((
|
||||
ref,
|
||||
@@ -189,9 +189,9 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||
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)}'
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user