Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b116b5f9b5 | ||
|
|
2f1bff8922 | ||
|
|
dd66c3834d | ||
|
|
548f4e92dc | ||
|
|
5311720a7e | ||
|
|
a723380560 |
+12
-1
@@ -368,7 +368,18 @@ tasks:
|
||||
|
||||
check-fast:
|
||||
desc: Pre-commit checks — analyze + unit+widget tests + coverage gate (no build, no integration)
|
||||
deps: [analyze, check-coverage, check-hygiene]
|
||||
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
|
||||
|
||||
check-hygiene:
|
||||
desc: Verify that no forbidden files (like home dir config) are tracked
|
||||
|
||||
@@ -6,8 +6,40 @@ 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 {
|
||||
@@ -123,11 +155,14 @@ 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))();
|
||||
// JSON-encoded List<{name,email}>
|
||||
TextColumn get participantsJson => text().withDefault(const Constant('[]'))();
|
||||
TextColumn get participantsJson => text()
|
||||
.withDefault(const Constant('[]'))
|
||||
.map(const EmailAddressListConverter())();
|
||||
TextColumn get preview => text().nullable()();
|
||||
TextColumn get latestEmailId => text()();
|
||||
TextColumn get emailIdsJson => text().withDefault(const Constant('[]'))();
|
||||
TextColumn get emailIdsJson => text()
|
||||
.withDefault(const Constant('[]'))
|
||||
.map(const StringListConverter())();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {accountId, mailboxPath, id};
|
||||
@@ -411,10 +446,18 @@ class AppDatabase extends _$AppDatabase {
|
||||
preview: Value(latest.preview),
|
||||
latestEmailId: latest.id,
|
||||
emailIdsJson: Value(
|
||||
jsonEncode(threadEmails.map((e) => e.id).toList()),
|
||||
threadEmails.map((e) => e.id).toList(),
|
||||
),
|
||||
participantsJson: Value(
|
||||
latest.fromJson,
|
||||
(jsonDecode(latest.fromJson) as List<dynamic>)
|
||||
.map(
|
||||
(e) => EmailAddress(
|
||||
name:
|
||||
(e as Map<String, dynamic>)['name'] as String?,
|
||||
email: e['email'] as String,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
), // Good enough for migration
|
||||
),
|
||||
);
|
||||
|
||||
@@ -92,18 +92,6 @@ 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,
|
||||
@@ -113,10 +101,10 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
messageCount: row.messageCount,
|
||||
hasUnread: row.hasUnread,
|
||||
isFlagged: row.isFlagged,
|
||||
participants: parseAddresses(row.participantsJson),
|
||||
participants: row.participantsJson,
|
||||
preview: row.preview,
|
||||
latestEmailId: row.latestEmailId,
|
||||
emailIds: List<String>.from(jsonDecode(row.emailIdsJson) as List),
|
||||
emailIds: row.emailIdsJson,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -156,13 +144,11 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
|
||||
// Collect unique participants across the whole thread.
|
||||
final seen = <String>{};
|
||||
final participants = <Map<String, dynamic>>[];
|
||||
final participants = <model.EmailAddress>[];
|
||||
for (final e in threadEmails) {
|
||||
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});
|
||||
for (final a in _parseAddresses(e.fromJson)) {
|
||||
if (seen.add(a.email)) {
|
||||
participants.add(a);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -177,12 +163,10 @@ class EmailRepositoryImpl implements EmailRepository {
|
||||
messageCount: Value(threadEmails.length),
|
||||
hasUnread: Value(threadEmails.any((e) => !e.isSeen)),
|
||||
isFlagged: Value(threadEmails.any((e) => e.isFlagged)),
|
||||
participantsJson: Value(jsonEncode(participants)),
|
||||
participantsJson: Value(participants),
|
||||
preview: Value(latest.preview),
|
||||
latestEmailId: latest.id,
|
||||
emailIdsJson: Value(
|
||||
jsonEncode(threadEmails.map((e) => e.id).toList()),
|
||||
),
|
||||
emailIdsJson: Value(threadEmails.map((e) => e.id).toList()),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -2708,18 +2692,6 @@ 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,
|
||||
@@ -2728,9 +2700,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,
|
||||
@@ -2766,6 +2738,18 @@ 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,5 +1,6 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_html/flutter_html.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
@@ -17,6 +18,14 @@ 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;
|
||||
@@ -60,20 +69,27 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
tooltip: 'Reply',
|
||||
onPressed: header == null
|
||||
? null
|
||||
: () => _reply(context, header, body, replyAll: false),
|
||||
: () {
|
||||
unawaited(_reply(context, header, body, replyAll: false));
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.reply_all),
|
||||
tooltip: 'Reply all',
|
||||
onPressed: header == null
|
||||
? null
|
||||
: () => _reply(context, header, body, replyAll: true),
|
||||
: () {
|
||||
unawaited(_reply(context, header, body, replyAll: true));
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.forward),
|
||||
tooltip: 'Forward',
|
||||
onPressed:
|
||||
header == null ? null : () => _forward(context, header, body),
|
||||
onPressed: header == null
|
||||
? null
|
||||
: () {
|
||||
unawaited(_forward(context, header, body));
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.mark_email_unread_outlined),
|
||||
@@ -263,26 +279,31 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
String _quotedBody(Email header, EmailBody? body) {
|
||||
Future<String> _quotedBody(Email header, EmailBody? body) async {
|
||||
final date = header.sentAt != null ? _dateFmt.format(header.sentAt!) : '';
|
||||
final from =
|
||||
header.from.isNotEmpty ? header.from.first.toString() : '(unknown)';
|
||||
final text = body?.textBody ?? htmlToPlain(body?.htmlBody ?? '');
|
||||
final rawText = body?.textBody;
|
||||
final text = (rawText != null && rawText.isNotEmpty)
|
||||
? rawText
|
||||
: await compute(htmlToPlain, body?.htmlBody ?? '');
|
||||
final quoted = text.trim().split('\n').map((l) => '> $l').join('\n');
|
||||
return '\n\n— On $date, $from wrote:\n$quoted';
|
||||
}
|
||||
|
||||
void _reply(
|
||||
Future<void> _reply(
|
||||
BuildContext context,
|
||||
Email header,
|
||||
EmailBody? body, {
|
||||
required bool replyAll,
|
||||
}) {
|
||||
}) async {
|
||||
final to = header.from.isNotEmpty ? header.from.first.email : '';
|
||||
final subject = (header.subject?.startsWith('Re:') ?? false)
|
||||
? header.subject!
|
||||
: 'Re: ${header.subject ?? ''}';
|
||||
final cc = replyAll ? header.to.map((a) => a.email).join(', ') : '';
|
||||
final quoted = await _quotedBody(header, body);
|
||||
if (!context.mounted) return;
|
||||
unawaited(
|
||||
context.push(
|
||||
'/compose',
|
||||
@@ -290,23 +311,29 @@ class _EmailDetailScreenState extends ConsumerState<EmailDetailScreen> {
|
||||
'replyToEmailId': widget.emailId,
|
||||
'prefillTo': to,
|
||||
'prefillSubject': subject,
|
||||
'prefillBody': _quotedBody(header, body),
|
||||
'prefillBody': quoted,
|
||||
if (cc.isNotEmpty) 'prefillCc': cc,
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _forward(BuildContext context, Email header, EmailBody? body) {
|
||||
Future<void> _forward(
|
||||
BuildContext context,
|
||||
Email header,
|
||||
EmailBody? body,
|
||||
) async {
|
||||
final subject = (header.subject?.startsWith('Fwd:') ?? false)
|
||||
? header.subject!
|
||||
: 'Fwd: ${header.subject ?? ''}';
|
||||
final quoted = await _quotedBody(header, body);
|
||||
if (!context.mounted) return;
|
||||
unawaited(
|
||||
context.push(
|
||||
'/compose',
|
||||
extra: {
|
||||
'prefillSubject': subject,
|
||||
'prefillBody': _quotedBody(header, body),
|
||||
'prefillBody': quoted,
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -534,7 +561,11 @@ class _SafeHtmlState extends State<_SafeHtml> {
|
||||
(_) => ErrorWidget.builder = prev,
|
||||
);
|
||||
|
||||
return Html(data: widget.data, extensions: widget.extensions);
|
||||
return Html(
|
||||
data: widget.data,
|
||||
extensions: widget.extensions,
|
||||
onLinkTap: _openLink,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,14 @@ import 'package:sharedinbox/ui/widgets/folder_drawer.dart';
|
||||
import 'package:sharedinbox/ui/widgets/snooze_picker.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({
|
||||
@@ -641,7 +649,7 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
||||
const Icon(Icons.star, color: Colors.amber, size: 16),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
_dateFmt.format(t.latestDate),
|
||||
_fmtDate(t.latestDate),
|
||||
style: Theme.of(ctx).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
|
||||
@@ -10,6 +10,7 @@ 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');
|
||||
|
||||
@@ -168,6 +169,18 @@ 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(
|
||||
|
||||
@@ -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']),
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
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.
|
After Width: | Height: | Size: 32 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
Reference in New Issue
Block a user