Compare commits

..
Author SHA1 Message Date
Thomas SharedInboxandClaude Sonnet 4.6 8f34f82bdb perf: cache formatted date strings in EmailListScreen (P5)
Add a module-level map that memoises DateFormat.format() results keyed
by local calendar day. In a 50-item list where many emails share the
same date, this reduces format() calls from O(n) to O(distinct days).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 11:16:10 +02:00
12 changed files with 47 additions and 268 deletions
+1 -12
View File
@@ -368,18 +368,7 @@ tasks:
check-fast:
desc: Pre-commit checks — analyze + unit+widget tests + coverage gate (no build, no integration)
deps: [analyze, check-coverage, check-hygiene, check-layers]
check-layers:
desc: Enforce architecture — ui/ must not import data/ (only core/ interfaces allowed)
cmds:
- |
VIOLATIONS=$(grep -rn "package:sharedinbox/data/" lib/ui/ 2>/dev/null || true)
if [ -n "$VIOLATIONS" ]; then
echo "ERROR: UI layer imports data layer (only core/ interfaces are allowed from ui/):"
echo "$VIOLATIONS"
exit 1
fi
deps: [analyze, check-coverage, check-hygiene]
check-hygiene:
desc: Verify that no forbidden files (like home dir config) are tracked
+5 -48
View File
@@ -6,40 +6,8 @@ import 'package:drift/native.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:sharedinbox/core/models/email.dart';
part 'database.g.dart';
// ── TypeConverters ────────────────────────────────────────────────────────────
class EmailAddressListConverter
extends TypeConverter<List<EmailAddress>, String> {
const EmailAddressListConverter();
@override
List<EmailAddress> fromSql(String fromDb) {
final list = jsonDecode(fromDb) as List<dynamic>;
return list
.map((e) => EmailAddress.fromJson(e as Map<String, dynamic>))
.toList();
}
@override
String toSql(List<EmailAddress> value) =>
jsonEncode(value.map((e) => e.toJson()).toList());
}
class StringListConverter extends TypeConverter<List<String>, String> {
const StringListConverter();
@override
List<String> fromSql(String fromDb) =>
List<String>.from(jsonDecode(fromDb) as List);
@override
String toSql(List<String> value) => jsonEncode(value);
}
// ── Tables ────────────────────────────────────────────────────────────────────
class Accounts extends Table {
@@ -155,14 +123,11 @@ class Threads extends Table {
IntColumn get messageCount => integer().withDefault(const Constant(1))();
BoolColumn get hasUnread => boolean().withDefault(const Constant(false))();
BoolColumn get isFlagged => boolean().withDefault(const Constant(false))();
TextColumn get participantsJson => text()
.withDefault(const Constant('[]'))
.map(const EmailAddressListConverter())();
// JSON-encoded List<{name,email}>
TextColumn get participantsJson => text().withDefault(const Constant('[]'))();
TextColumn get preview => text().nullable()();
TextColumn get latestEmailId => text()();
TextColumn get emailIdsJson => text()
.withDefault(const Constant('[]'))
.map(const StringListConverter())();
TextColumn get emailIdsJson => text().withDefault(const Constant('[]'))();
@override
Set<Column> get primaryKey => {accountId, mailboxPath, id};
@@ -446,18 +411,10 @@ class AppDatabase extends _$AppDatabase {
preview: Value(latest.preview),
latestEmailId: latest.id,
emailIdsJson: Value(
threadEmails.map((e) => e.id).toList(),
jsonEncode(threadEmails.map((e) => e.id).toList()),
),
participantsJson: Value(
(jsonDecode(latest.fromJson) as List<dynamic>)
.map(
(e) => EmailAddress(
name:
(e as Map<String, dynamic>)['name'] as String?,
email: e['email'] as String,
),
)
.toList(),
latest.fromJson,
), // Good enough for migration
),
);
@@ -92,6 +92,18 @@ class EmailRepositoryImpl implements EmailRepository {
}
model.EmailThread _threadRowToModel(ThreadRow row) {
List<model.EmailAddress> parseAddresses(String json) {
final list = jsonDecode(json) as List<dynamic>;
return list
.map(
(e) => model.EmailAddress(
name: (e as Map<String, dynamic>)['name'] as String?,
email: e['email'] as String,
),
)
.toList();
}
return model.EmailThread(
threadId: row.id,
accountId: row.accountId,
@@ -101,10 +113,10 @@ class EmailRepositoryImpl implements EmailRepository {
messageCount: row.messageCount,
hasUnread: row.hasUnread,
isFlagged: row.isFlagged,
participants: row.participantsJson,
participants: parseAddresses(row.participantsJson),
preview: row.preview,
latestEmailId: row.latestEmailId,
emailIds: row.emailIdsJson,
emailIds: List<String>.from(jsonDecode(row.emailIdsJson) as List),
);
}
@@ -144,11 +156,13 @@ class EmailRepositoryImpl implements EmailRepository {
// Collect unique participants across the whole thread.
final seen = <String>{};
final participants = <model.EmailAddress>[];
final participants = <Map<String, dynamic>>[];
for (final e in threadEmails) {
for (final a in _parseAddresses(e.fromJson)) {
if (seen.add(a.email)) {
participants.add(a);
final from = jsonDecode(e.fromJson) as List<dynamic>;
for (final a in from.cast<Map<String, dynamic>>()) {
final email = a['email'] as String;
if (seen.add(email)) {
participants.add({'name': a['name'], 'email': email});
}
}
}
@@ -163,10 +177,12 @@ class EmailRepositoryImpl implements EmailRepository {
messageCount: Value(threadEmails.length),
hasUnread: Value(threadEmails.any((e) => !e.isSeen)),
isFlagged: Value(threadEmails.any((e) => e.isFlagged)),
participantsJson: Value(participants),
participantsJson: Value(jsonEncode(participants)),
preview: Value(latest.preview),
latestEmailId: latest.id,
emailIdsJson: Value(threadEmails.map((e) => e.id).toList()),
emailIdsJson: Value(
jsonEncode(threadEmails.map((e) => e.id).toList()),
),
),
);
}
@@ -2692,6 +2708,18 @@ class EmailRepositoryImpl implements EmailRepository {
}
model.Email _toModel(Email row) {
List<model.EmailAddress> parseAddresses(String json) {
final list = jsonDecode(json) as List<dynamic>;
return list
.map(
(e) => model.EmailAddress(
name: (e as Map<String, dynamic>)['name'] as String?,
email: e['email'] as String,
),
)
.toList();
}
return model.Email(
id: row.id,
accountId: row.accountId,
@@ -2700,9 +2728,9 @@ class EmailRepositoryImpl implements EmailRepository {
subject: row.subject,
sentAt: row.sentAt,
receivedAt: row.receivedAt,
from: _parseAddresses(row.fromJson),
to: _parseAddresses(row.toAddresses),
cc: _parseAddresses(row.ccJson),
from: parseAddresses(row.fromJson),
to: parseAddresses(row.toAddresses),
cc: parseAddresses(row.ccJson),
preview: row.preview,
isSeen: row.isSeen,
isFlagged: row.isFlagged,
@@ -2738,18 +2766,6 @@ class EmailRepositoryImpl implements EmailRepository {
}
}
List<model.EmailAddress> _parseAddresses(String json) {
final list = jsonDecode(json) as List<dynamic>;
return list
.map(
(e) => model.EmailAddress(
name: (e as Map<String, dynamic>)['name'] as String?,
email: e['email'] as String,
),
)
.toList();
}
List<model.EmailAttachment> _parseAttachments(String json) {
final list = jsonDecode(json) as List<dynamic>;
return list
+1 -13
View File
@@ -18,14 +18,6 @@ import 'package:url_launcher/url_launcher.dart';
final _dateFmt = DateFormat('EEE, MMM d yyyy, HH:mm');
void _openLink(String? url, Map<String, String> attrs, dynamic _) {
if (url == null) return;
final uri = Uri.tryParse(url);
if (uri != null) {
unawaited(launchUrl(uri, mode: LaunchMode.externalApplication));
}
}
class EmailDetailScreen extends ConsumerStatefulWidget {
const EmailDetailScreen({super.key, required this.emailId});
final String emailId;
@@ -561,11 +553,7 @@ class _SafeHtmlState extends State<_SafeHtml> {
(_) => ErrorWidget.builder = prev,
);
return Html(
data: widget.data,
extensions: widget.extensions,
onLinkTap: _openLink,
);
return Html(data: widget.data, extensions: widget.extensions);
}
}
-13
View File
@@ -10,7 +10,6 @@ import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/core/models/undo_action.dart';
import 'package:sharedinbox/core/utils/html_utils.dart';
import 'package:sharedinbox/di.dart';
import 'package:url_launcher/url_launcher.dart';
final _dateFmt = DateFormat('EEE, MMM d, HH:mm');
@@ -169,18 +168,6 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
extensions: [
if (!_loadRemoteImages) _BlockRemoteImagesExtension(),
],
onLinkTap: (url, _, __) {
if (url == null) return;
final uri = Uri.tryParse(url);
if (uri != null) {
unawaited(
launchUrl(
uri,
mode: LaunchMode.externalApplication,
),
);
}
},
),
] else
SelectableText(
+1 -1
View File
@@ -332,7 +332,7 @@ void main() {
messageCount: const Value(2),
hasUnread: const Value(true),
latestEmailId: 'acc-1:2',
emailIdsJson: const Value(['acc-1:1', 'acc-1:2']),
emailIdsJson: const Value('["acc-1:1", "acc-1:2"]'),
),
);
@@ -1,158 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sharedinbox/core/models/email.dart';
import 'package:sharedinbox/di.dart';
import 'helpers.dart';
// Fixed-date emails so golden files don't change day to day.
final _kDate = DateTime(2024, 6);
Email _email({
String id = 'acc-1:1',
String subject = 'Hello world',
bool isSeen = true,
bool isFlagged = false,
}) =>
Email(
id: id,
accountId: 'acc-1',
mailboxPath: 'INBOX',
uid: int.parse(id.split(':').last),
subject: subject,
receivedAt: _kDate,
sentAt: _kDate,
from: const [EmailAddress(name: 'Bob', email: 'bob@example.com')],
to: const [EmailAddress(email: 'alice@example.com')],
cc: const [],
isSeen: isSeen,
isFlagged: isFlagged,
hasAttachment: false,
);
List<Override> _overrides({
List<Email> emails = const [],
List<Email> searchResults = const [],
String? syncError,
}) =>
[
accountRepositoryProvider.overrideWithValue(
FakeAccountRepository([kTestAccount]),
),
mailboxRepositoryProvider.overrideWithValue(
FakeMailboxRepository([kTestMailbox]),
),
emailRepositoryProvider.overrideWithValue(
FakeEmailRepository(emails: emails, searchResults: searchResults),
),
draftRepositoryProvider.overrideWithValue(FakeDraftRepository()),
searchHistoryRepositoryProvider.overrideWithValue(
FakeSearchHistoryRepository(),
),
syncLastErrorProvider.overrideWith(
(ref, _) => Stream.value(syncError),
),
];
void main() {
group('EmailListScreen goldens', () {
testWidgets('golden: empty state', (tester) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: _overrides(),
),
);
await tester.pumpAndSettle();
await expectLater(
find.byType(MaterialApp),
matchesGoldenFile('goldens/email_list_empty.png'),
);
});
testWidgets('golden: list with emails', (tester) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: _overrides(
emails: [
_email(subject: 'Team standup notes', isSeen: false),
_email(id: 'acc-1:2', subject: 'Q3 review', isFlagged: true),
_email(id: 'acc-1:3', subject: 'Welcome to the project'),
],
),
),
);
await tester.pumpAndSettle();
await expectLater(
find.byType(MaterialApp),
matchesGoldenFile('goldens/email_list_with_emails.png'),
);
});
testWidgets('golden: selection mode', (tester) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: _overrides(
emails: [
_email(subject: 'Team standup notes', isSeen: false),
_email(id: 'acc-1:2', subject: 'Q3 review'),
],
),
),
);
await tester.pumpAndSettle();
await tester.longPress(find.text('Team standup notes'));
await tester.pumpAndSettle();
await expectLater(
find.byType(MaterialApp),
matchesGoldenFile('goldens/email_list_selection.png'),
);
});
testWidgets('golden: search with results', (tester) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: _overrides(
searchResults: [
_email(id: 'acc-1:5', subject: 'Project proposal'),
],
),
),
);
await tester.pumpAndSettle();
await tester.enterText(find.byType(SearchBar), 'project');
await tester.testTextInput.receiveAction(TextInputAction.search);
await tester.pumpAndSettle();
await expectLater(
find.byType(MaterialApp),
matchesGoldenFile('goldens/email_list_search_results.png'),
);
});
testWidgets('golden: error banner', (tester) async {
await tester.pumpWidget(
buildApp(
initialLocation: '/accounts/acc-1/mailboxes/INBOX/emails',
overrides: _overrides(syncError: 'Connection refused'),
),
);
await tester.pumpAndSettle();
await expectLater(
find.byType(MaterialApp),
matchesGoldenFile('goldens/email_list_error_banner.png'),
);
});
});
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB