Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5464efe684 |
@@ -60,7 +60,20 @@ class AccountListScreen extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
final accounts = snap.data!;
|
final accounts = snap.data!;
|
||||||
if (accounts.isEmpty) {
|
if (accounts.isEmpty) {
|
||||||
return const _OnboardingView();
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Text('No accounts yet.'),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: () => context.push('/accounts/add'),
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
label: const Text('Add account'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return ListView.builder(
|
return ListView.builder(
|
||||||
itemCount: accounts.length,
|
itemCount: accounts.length,
|
||||||
@@ -220,112 +233,6 @@ class _AccountTile extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _OnboardingView extends StatelessWidget {
|
|
||||||
const _OnboardingView();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
return Center(
|
|
||||||
child: SingleChildScrollView(
|
|
||||||
padding: const EdgeInsets.all(24),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.mail_outline,
|
|
||||||
size: 64,
|
|
||||||
color: theme.colorScheme.primary,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
'Welcome to SharedInbox',
|
|
||||||
style: theme.textTheme.headlineSmall,
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
'Get started in three steps:',
|
|
||||||
style: theme.textTheme.bodyMedium,
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
const _Step(
|
|
||||||
number: '1',
|
|
||||||
title: 'Add an account',
|
|
||||||
description: 'Connect your IMAP or JMAP email account.',
|
|
||||||
),
|
|
||||||
const _Step(
|
|
||||||
number: '2',
|
|
||||||
title: 'Wait for sync',
|
|
||||||
description:
|
|
||||||
'SharedInbox downloads your messages in the background.',
|
|
||||||
),
|
|
||||||
const _Step(
|
|
||||||
number: '3',
|
|
||||||
title: 'Open your inbox',
|
|
||||||
description:
|
|
||||||
'Tap the account to browse mailboxes and read emails.',
|
|
||||||
),
|
|
||||||
const SizedBox(height: 32),
|
|
||||||
FilledButton.icon(
|
|
||||||
onPressed: () => context.push('/accounts/add'),
|
|
||||||
icon: const Icon(Icons.add),
|
|
||||||
label: const Text('Add account'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _Step extends StatelessWidget {
|
|
||||||
const _Step({
|
|
||||||
required this.number,
|
|
||||||
required this.title,
|
|
||||||
required this.description,
|
|
||||||
});
|
|
||||||
|
|
||||||
final String number;
|
|
||||||
final String title;
|
|
||||||
final String description;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
CircleAvatar(
|
|
||||||
radius: 16,
|
|
||||||
backgroundColor: theme.colorScheme.primaryContainer,
|
|
||||||
child: Text(
|
|
||||||
number,
|
|
||||||
style: TextStyle(
|
|
||||||
color: theme.colorScheme.onPrimaryContainer,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 16),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(title, style: theme.textTheme.titleSmall),
|
|
||||||
Text(description, style: theme.textTheme.bodySmall),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum _AccountAction { syncLog, verifySync, edit, emailFilters, delete }
|
enum _AccountAction { syncLog, verifySync, edit, emailFilters, delete }
|
||||||
|
|
||||||
/// Whether to surface the "Email filters" (Sieve) entry for [account].
|
/// Whether to surface the "Email filters" (Sieve) entry for [account].
|
||||||
|
|||||||
@@ -18,14 +18,6 @@ import 'package:url_launcher/url_launcher.dart';
|
|||||||
|
|
||||||
final _dateFmt = DateFormat('EEE, MMM d yyyy, HH:mm');
|
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 {
|
class EmailDetailScreen extends ConsumerStatefulWidget {
|
||||||
const EmailDetailScreen({super.key, required this.emailId});
|
const EmailDetailScreen({super.key, required this.emailId});
|
||||||
final String emailId;
|
final String emailId;
|
||||||
@@ -561,11 +553,7 @@ class _SafeHtmlState extends State<_SafeHtml> {
|
|||||||
(_) => ErrorWidget.builder = prev,
|
(_) => ErrorWidget.builder = prev,
|
||||||
);
|
);
|
||||||
|
|
||||||
return Html(
|
return Html(data: widget.data, extensions: widget.extensions);
|
||||||
data: widget.data,
|
|
||||||
extensions: widget.extensions,
|
|
||||||
onLinkTap: _openLink,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,14 +15,6 @@ import 'package:sharedinbox/ui/widgets/folder_drawer.dart';
|
|||||||
import 'package:sharedinbox/ui/widgets/snooze_picker.dart';
|
import 'package:sharedinbox/ui/widgets/snooze_picker.dart';
|
||||||
|
|
||||||
final _dateFmt = DateFormat('MMM d');
|
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 {
|
class EmailListScreen extends ConsumerStatefulWidget {
|
||||||
const EmailListScreen({
|
const EmailListScreen({
|
||||||
@@ -649,7 +641,7 @@ class _EmailListScreenState extends ConsumerState<EmailListScreen> {
|
|||||||
const Icon(Icons.star, color: Colors.amber, size: 16),
|
const Icon(Icons.star, color: Colors.amber, size: 16),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text(
|
Text(
|
||||||
_fmtDate(t.latestDate),
|
_dateFmt.format(t.latestDate),
|
||||||
style: Theme.of(ctx).textTheme.bodySmall,
|
style: Theme.of(ctx).textTheme.bodySmall,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import 'package:sharedinbox/core/models/email.dart';
|
|||||||
import 'package:sharedinbox/core/models/undo_action.dart';
|
import 'package:sharedinbox/core/models/undo_action.dart';
|
||||||
import 'package:sharedinbox/core/utils/html_utils.dart';
|
import 'package:sharedinbox/core/utils/html_utils.dart';
|
||||||
import 'package:sharedinbox/di.dart';
|
import 'package:sharedinbox/di.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
|
||||||
|
|
||||||
final _dateFmt = DateFormat('EEE, MMM d, HH:mm');
|
final _dateFmt = DateFormat('EEE, MMM d, HH:mm');
|
||||||
|
|
||||||
@@ -169,18 +168,6 @@ class _EmailMessageCardState extends ConsumerState<_EmailMessageCard> {
|
|||||||
extensions: [
|
extensions: [
|
||||||
if (!_loadRemoteImages) _BlockRemoteImagesExtension(),
|
if (!_loadRemoteImages) _BlockRemoteImagesExtension(),
|
||||||
],
|
],
|
||||||
onLinkTap: (url, _, __) {
|
|
||||||
if (url == null) return;
|
|
||||||
final uri = Uri.tryParse(url);
|
|
||||||
if (uri != null) {
|
|
||||||
unawaited(
|
|
||||||
launchUrl(
|
|
||||||
uri,
|
|
||||||
mode: LaunchMode.externalApplication,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
] else
|
] else
|
||||||
SelectableText(
|
SelectableText(
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import 'helpers.dart';
|
|||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
group('AccountListScreen', () {
|
group('AccountListScreen', () {
|
||||||
testWidgets('shows onboarding walkthrough when repository is empty', (
|
testWidgets('shows "No accounts yet." when repository is empty', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
@@ -13,7 +13,7 @@ void main() {
|
|||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('Welcome to SharedInbox'), findsOneWidget);
|
expect(find.text('No accounts yet.'), findsOneWidget);
|
||||||
expect(find.text('Add account'), findsOneWidget);
|
expect(find.text('Add account'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -203,7 +203,7 @@ void main() {
|
|||||||
await tester.tap(find.text('Save'));
|
await tester.tap(find.text('Save'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('Welcome to SharedInbox'), findsOneWidget);
|
expect(find.text('No accounts yet.'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('JMAP connection failure shows error message', (tester) async {
|
testWidgets('JMAP connection failure shows error message', (tester) async {
|
||||||
@@ -284,7 +284,7 @@ void main() {
|
|||||||
await tester.tap(find.text('Save'));
|
await tester.tap(find.text('Save'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('Welcome to SharedInbox'), findsOneWidget);
|
expect(find.text('No accounts yet.'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets(
|
testWidgets(
|
||||||
|
|||||||
@@ -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 |
Reference in New Issue
Block a user