diff --git a/lib/core/repositories/email_repository.dart b/lib/core/repositories/email_repository.dart index 2ce430f..28466bf 100644 --- a/lib/core/repositories/email_repository.dart +++ b/lib/core/repositories/email_repository.dart @@ -15,6 +15,10 @@ abstract class EmailRepository { int limit = 50, }); + /// Returns threads from the INBOX mailbox of every account, sorted by latest + /// message date descending. Inbox mailboxes are identified by role = 'inbox'. + Stream> observeAllInboxThreads({int limit = 50}); + /// Returns all emails belonging to [threadId] in [mailboxPath]. Stream> observeEmailsInThread( String accountId, diff --git a/lib/data/repositories/email_repository_impl.dart b/lib/data/repositories/email_repository_impl.dart index 5179e15..6b0cad9 100644 --- a/lib/data/repositories/email_repository_impl.dart +++ b/lib/data/repositories/email_repository_impl.dart @@ -95,6 +95,26 @@ class EmailRepositoryImpl implements EmailRepository { .map((rows) => rows.map(_threadRowToModel).toList()); } + @override + Stream> observeAllInboxThreads({int limit = 50}) { + final query = _db.select(_db.threads).join([ + innerJoin( + _db.mailboxes, + _db.mailboxes.accountId.equalsExp(_db.threads.accountId) & + _db.mailboxes.path.equalsExp(_db.threads.mailboxPath), + ), + ]); + query + ..where(_db.mailboxes.role.equals('inbox')) + ..orderBy([OrderingTerm.desc(_db.threads.latestDate)]) + ..limit(limit); + return query.watch().map( + (rows) => rows + .map((row) => _threadRowToModel(row.readTable(_db.threads))) + .toList(), + ); + } + model.EmailThread _threadRowToModel(ThreadRow row) { List parseAddresses(String json) { final list = jsonDecode(json) as List; diff --git a/lib/di.dart b/lib/di.dart index faf9ceb..a947f35 100644 --- a/lib/di.dart +++ b/lib/di.dart @@ -239,6 +239,10 @@ class EmailDetailNotifier extends AsyncNotifier<(Email?, EmailBody)> { } } +final allAccountsProvider = StreamProvider>((ref) { + return ref.watch(accountRepositoryProvider).observeAccounts(); +}); + final accountByIdProvider = StreamProvider.autoDispose.family((ref, accountId) { return ref.watch(accountRepositoryProvider).observeAccounts().map( diff --git a/lib/ui/router.dart b/lib/ui/router.dart index dcc1c66..1fd35a2 100644 --- a/lib/ui/router.dart +++ b/lib/ui/router.dart @@ -9,6 +9,7 @@ import 'package:sharedinbox/ui/screens/account_send_screen.dart'; import 'package:sharedinbox/ui/screens/add_account_screen.dart'; import 'package:sharedinbox/ui/screens/address_emails_screen.dart'; import 'package:sharedinbox/ui/screens/changelog_screen.dart'; +import 'package:sharedinbox/ui/screens/combined_inbox_screen.dart'; import 'package:sharedinbox/ui/screens/compose_screen.dart'; import 'package:sharedinbox/ui/screens/edit_account_screen.dart'; import 'package:sharedinbox/ui/screens/email_detail_screen.dart'; @@ -24,11 +25,15 @@ import 'package:sharedinbox/ui/screens/user_preferences_screen.dart'; import 'package:sharedinbox/ui/widgets/undo_shell.dart'; final router = GoRouter( - initialLocation: '/accounts', + initialLocation: '/inbox', routes: [ ShellRoute( builder: (ctx, state, child) => UndoShell(child: child), routes: [ + GoRoute( + path: '/inbox', + builder: (ctx, state) => const CombinedInboxScreen(), + ), GoRoute( path: '/accounts', builder: (ctx, state) => const AccountListScreen(), diff --git a/lib/ui/screens/combined_inbox_screen.dart b/lib/ui/screens/combined_inbox_screen.dart new file mode 100644 index 0000000..4740647 --- /dev/null +++ b/lib/ui/screens/combined_inbox_screen.dart @@ -0,0 +1,393 @@ +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'; + +final _dateFmt = DateFormat('MMM d'); +final _formattedDates = {}; + +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}); + + @override + ConsumerState createState() => + _CombinedInboxScreenState(); +} + +class _CombinedInboxScreenState extends ConsumerState { + static const _pageSize = 50; + int _limit = _pageSize; + + @override + Widget build(BuildContext context) { + final accountsAsync = ref.watch(allAccountsProvider); + + return accountsAsync.when( + loading: () => const Scaffold( + body: Center(child: CircularProgressIndicator()), + ), + error: (e, _) => Scaffold( + body: Center(child: Text('Error: $e')), + ), + data: (accounts) { + if (accounts.isEmpty) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (context.mounted) context.go('/accounts'); + }); + return const Scaffold( + body: Center(child: CircularProgressIndicator()), + ); + } + + final accountNames = { + for (final a in accounts) a.id: a.displayName, + }; + final showAccount = accounts.length > 1; + + return Scaffold( + appBar: _buildAppBar(accounts), + drawer: _buildDrawer(context, accounts), + body: _buildBody(accountNames, showAccount), + floatingActionButton: FloatingActionButton( + onPressed: () => context.push('/compose'), + child: const Icon(Icons.edit), + ), + ); + }, + ); + } + + PreferredSizeWidget _buildAppBar(List accounts) { + return AppBar( + title: const Text('Combined Inbox'), + actions: [ + IconButton( + icon: const Icon(Icons.search), + tooltip: 'Search', + onPressed: () => context.push('/search'), + ), + IconButton( + icon: const Icon(Icons.sync), + tooltip: 'Sync all', + onPressed: () { + for (final a in accounts) { + ref.read(syncManagerProvider).syncNow(a.id); + } + }, + ), + ], + ); + } + + Widget _buildDrawer(BuildContext context, List accounts) { + return Drawer( + child: ListView( + padding: EdgeInsets.zero, + children: [ + const DrawerHeader( + decoration: BoxDecoration(color: Colors.blueGrey), + child: Text( + 'sharedinbox.de', + style: TextStyle(color: Colors.white, fontSize: 24), + ), + ), + ListTile( + leading: const Icon(Icons.manage_accounts), + title: const Text('Accounts'), + onTap: () { + Navigator.pop(context); + context.go('/accounts'); + }, + ), + ListTile( + leading: const Icon(Icons.person_add), + title: const Text('Add account'), + onTap: () { + Navigator.pop(context); + unawaited(context.push('/accounts/add')); + }, + ), + const Divider(), + for (final account in accounts) + ListTile( + leading: const Icon(Icons.inbox), + title: Text(account.displayName), + subtitle: Text(account.email), + onTap: () { + Navigator.pop(context); + unawaited(context.push('/accounts/${account.id}/mailboxes')); + }, + ), + const Divider(), + ListTile( + leading: const Icon(Icons.settings), + title: const Text('Preferences'), + onTap: () { + Navigator.pop(context); + unawaited(context.push('/accounts/preferences')); + }, + ), + ListTile( + leading: const Icon(Icons.history), + title: const Text('Undo Log'), + onTap: () { + Navigator.pop(context); + unawaited(context.push('/accounts/undo-log')); + }, + ), + ListTile( + leading: const Icon(Icons.info_outline), + title: const Text('About'), + onTap: () { + Navigator.pop(context); + unawaited(context.push('/accounts/about')); + }, + ), + ], + ), + ); + } + + Widget _buildBody(Map accountNames, bool showAccount) { + final emailRepo = ref.watch(emailRepositoryProvider); + return RefreshIndicator( + onRefresh: () async { + final accounts = ref.read(allAccountsProvider).value ?? []; + for (final a in accounts) { + ref.read(syncManagerProvider).syncNow(a.id); + } + }, + child: StreamBuilder>( + stream: emailRepo.observeAllInboxThreads(limit: _limit), + builder: (ctx, snap) { + if (!snap.hasData) { + return const Center(child: CircularProgressIndicator()); + } + final threads = snap.data!; + if (threads.isEmpty) { + return ListView( + children: const [ + SizedBox( + height: 300, + child: Center(child: Text('No emails')), + ), + ], + ); + } + return _buildThreadList(threads, accountNames, showAccount); + }, + ), + ); + } + + Widget _buildThreadList( + List threads, + Map accountNames, + bool showAccount, + ) { + final hasMore = threads.length == _limit; + return ListView.builder( + itemCount: threads.length + (hasMore ? 1 : 0), + itemBuilder: (ctx, i) { + if (i == threads.length) { + return TextButton( + onPressed: () => setState(() => _limit += _pageSize), + child: const Text('Load more'), + ); + } + return _buildThreadTile(ctx, threads[i], accountNames, showAccount); + }, + ); + } + + Widget _buildThreadTile( + BuildContext ctx, + EmailThread t, + Map 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, + ), + ], + ), + onTap: t.messageCount > 1 + ? () => context.push( + '/accounts/${t.accountId}/mailboxes' + '/${Uri.encodeComponent(t.mailboxPath)}' + '/threads/${Uri.encodeComponent(t.threadId)}', + ) + : () => context.push( + '/accounts/${t.accountId}/mailboxes' + '/${Uri.encodeComponent(t.mailboxPath)}' + '/emails/${Uri.encodeComponent(t.latestEmailId)}', + ), + ); + + return Dismissible( + key: ValueKey('${t.accountId}:${t.threadId}'), + background: _swipeBackground( + alignment: Alignment.centerLeft, + color: Colors.green, + icon: Icons.archive, + label: 'Archive', + ), + secondaryBackground: _swipeBackground( + alignment: Alignment.centerRight, + color: Colors.red, + icon: Icons.delete, + label: 'Delete', + ), + onDismissed: (direction) => unawaited(_onSwipeDismissed(t, direction)), + child: tile, + ); + } + + Future _onSwipeDismissed( + EmailThread t, + DismissDirection direction, + ) async { + final repo = ref.read(emailRepositoryProvider); + + final originalEmails = (await Future.wait( + t.emailIds.map((id) => repo.getEmail(id)), + )) + .whereType() + .toList(); + + if (direction == DismissDirection.startToEnd) { + final archive = await ref + .read(mailboxRepositoryProvider) + .findMailboxByRole(t.accountId, 'archive'); + if (!mounted || archive == null) return; + + for (final id in t.emailIds) { + await repo.moveEmail(id, archive.path); + } + final action = UndoAction( + id: DateTime.now().toIso8601String(), + accountId: t.accountId, + type: UndoType.move, + emailIds: t.emailIds, + sourceMailboxPath: t.mailboxPath, + destinationMailboxPath: archive.path, + originalEmails: originalEmails, + ); + unawaited(ref.read(undoServiceProvider.notifier).pushAction(action)); + return; + } + + String? lastDestPath; + for (final id in t.emailIds) { + lastDestPath = await repo.deleteEmail(id); + } + final action = UndoAction( + id: DateTime.now().toIso8601String(), + accountId: t.accountId, + type: UndoType.delete, + emailIds: t.emailIds, + sourceMailboxPath: t.mailboxPath, + destinationMailboxPath: lastDestPath, + originalEmails: originalEmails, + ); + unawaited(ref.read(undoServiceProvider.notifier).pushAction(action)); + } + + Widget _swipeBackground({ + required AlignmentGeometry alignment, + required Color color, + required IconData icon, + required String label, + }) { + return Container( + color: color, + alignment: alignment, + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: Colors.white), + const SizedBox(width: 8), + Text(label, style: const TextStyle(color: Colors.white)), + ], + ), + ); + } +} diff --git a/scripts/check_coverage.dart b/scripts/check_coverage.dart index f06ac2c..f910024 100644 --- a/scripts/check_coverage.dart +++ b/scripts/check_coverage.dart @@ -42,6 +42,7 @@ const _excluded = { 'lib/ui/screens/add_account_screen.dart', 'lib/ui/screens/address_emails_screen.dart', 'lib/ui/screens/changelog_screen.dart', + 'lib/ui/screens/combined_inbox_screen.dart', 'lib/ui/screens/compose_screen.dart', 'lib/ui/screens/crash_screen.dart', 'lib/ui/screens/edit_account_screen.dart', diff --git a/test/backend/account_sync_manager_test.dart b/test/backend/account_sync_manager_test.dart index 48e8212..4aafb9c 100644 --- a/test/backend/account_sync_manager_test.dart +++ b/test/backend/account_sync_manager_test.dart @@ -186,6 +186,10 @@ class _FakeEmails implements EmailRepository { }) => Stream.value([]); + @override + Stream> observeAllInboxThreads({int limit = 50}) => + Stream.value([]); + @override Stream> observeEmailsInThread(String a, String m, String t) => Stream.value([]); diff --git a/test/unit/account_sync_manager_test.dart b/test/unit/account_sync_manager_test.dart index f03fe70..1b17daa 100644 --- a/test/unit/account_sync_manager_test.dart +++ b/test/unit/account_sync_manager_test.dart @@ -81,6 +81,9 @@ class FakeEmailRepository implements EmailRepository { }) => Stream.value([]); @override + Stream> observeAllInboxThreads({int limit = 50}) => + Stream.value([]); + @override Stream> observeEmailsInThread(String a, String m, String t) => Stream.value([]); @override diff --git a/test/unit/account_sync_manager_test.mocks.dart b/test/unit/account_sync_manager_test.mocks.dart index e99e759..481ba08 100644 --- a/test/unit/account_sync_manager_test.mocks.dart +++ b/test/unit/account_sync_manager_test.mocks.dart @@ -287,6 +287,17 @@ class MockEmailRepository extends _i1.Mock implements _i9.EmailRepository { returnValue: _i5.Stream>.empty(), ) as _i5.Stream>); + @override + _i5.Stream> observeAllInboxThreads({int? limit = 50}) => + (super.noSuchMethod( + Invocation.method( + #observeAllInboxThreads, + [], + {#limit: limit}, + ), + returnValue: _i5.Stream>.empty(), + ) as _i5.Stream>); + @override _i5.Stream> observeEmailsInThread( String? accountId, diff --git a/test/unit/reliability_runner_check_now_test.dart b/test/unit/reliability_runner_check_now_test.dart index e823b2f..86fe5af 100644 --- a/test/unit/reliability_runner_check_now_test.dart +++ b/test/unit/reliability_runner_check_now_test.dart @@ -103,6 +103,9 @@ class _FakeEmails implements EmailRepository { }) => Stream.value([]); @override + Stream> observeAllInboxThreads({int limit = 50}) => + Stream.value([]); + @override Stream> observeEmailsInThread(String a, String m, String t) => Stream.value([]); @override diff --git a/test/unit/reliability_runner_test.dart b/test/unit/reliability_runner_test.dart index 4b76606..f7a8b03 100644 --- a/test/unit/reliability_runner_test.dart +++ b/test/unit/reliability_runner_test.dart @@ -102,6 +102,9 @@ class _CountingEmails implements EmailRepository { }) => Stream.value([]); @override + Stream> observeAllInboxThreads({int limit = 50}) => + Stream.value([]); + @override Stream> observeEmailsInThread(String a, String m, String t) => Stream.value([]); @override diff --git a/test/unit/undo_service_test.mocks.dart b/test/unit/undo_service_test.mocks.dart index cf3d41d..e1ea257 100644 --- a/test/unit/undo_service_test.mocks.dart +++ b/test/unit/undo_service_test.mocks.dart @@ -109,6 +109,17 @@ class MockEmailRepository extends _i1.Mock implements _i3.EmailRepository { returnValue: _i4.Stream>.empty(), ) as _i4.Stream>); + @override + _i4.Stream> observeAllInboxThreads({int? limit = 50}) => + (super.noSuchMethod( + Invocation.method( + #observeAllInboxThreads, + [], + {#limit: limit}, + ), + returnValue: _i4.Stream>.empty(), + ) as _i4.Stream>); + @override _i4.Stream> observeEmailsInThread( String? accountId, diff --git a/test/widget/helpers.dart b/test/widget/helpers.dart index bfd5515..4a504bf 100644 --- a/test/widget/helpers.dart +++ b/test/widget/helpers.dart @@ -245,6 +245,10 @@ class FakeEmailRepository implements EmailRepository { }).toList(); }); + @override + Stream> observeAllInboxThreads({int limit = 50}) => + Stream.value([]); + @override Stream> observeEmailsInThread( String accountId,